# gentle-committer

> **gentle-committer** is an independent commit-automation extension for the [pi coding agent](https://pi.ai), originally derived from [pi-committer](https://github.com/tmonk/pi-committer) v0.12.8 and adapted for the [gentle-pi](https://github.com/Gentleman-Programming/gentle-pi) ecosystem. All credit for the original commit automation goes to the pi-committer contributors; this project is MIT under the same terms.

Commit automation for the [pi coding agent](https://pi.ai) with a **`commit_mode` switch**: commit directly (`bypass`) **or** route each commit through gentle-ai's review gate (`review`) — plus optional **work-unit commit discipline** injected into the commit-message prompts.

| Mode | What happens | When to use |
| --- | --- | --- |
| `bypass` (default) | stage → generate message → `git commit` (byte-identical to pi-committer) | everyday work; developing gentle-committer itself |
| `review` | stage + generate message → write a message file → hand off a `git commit -F` directive so gentle-ai's gate reviews the commit before it lands | features/changes you want reviewed before they land |

Override per-commit without touching config: `commit_changes({mode: "review"})` for a big feature, `commit_changes({mode: "bypass"})` for a quick fix.

```bash
pi install npm:@chaoxu1997/gentle-committer
```

## What's different from pi-committer

| Area | pi-committer | gentle-committer |
| --- | --- | --- |
| Commit path | always `execSync` direct | `bypass` (direct) **or** `review` (gentle-ai gate) |
| Per-commit mode | — | `commit_changes({mode})` override |
| Commit-message prompt | fixed | + optional work-unit rules (`prompt_rules_path`) |
| gentle-ai coupling | — | **none** — review fires via the existing bash gate |
| Everything else | — | unchanged (staged commits, subagent, multi-repo, async, widget) |

## Quick start

1. **Install** (above) — pi auto-loads it on startup.
2. **Enable + pick a mode** in `.pi-committer.toml`:

   ```toml
   [committer]
   enabled     = true
   commit_mode = "bypass"   # or "review"
   ```

3. **Commit**: ask the agent to commit, run `/commit`, or (with `trigger_mode = "on_goal"`) let it auto-commit when a [pi-goal](https://pi.dev/packages/@capyup/pi-goal) completes.

## Commit modes

### `bypass` (default) — direct commit

Stage → subagent generates a conventional message → `git commit`. Byte-identical to upstream pi-committer. No gate.

### `review` — commit through gentle-ai's gate

Stage + generate the message, **but do not commit**. Instead:

```text
commit_changes({mode:"review"})
  → stages files, generates message
  → writes .git/gentle_committer_pending_msg
  → returns a directive (pending: true, NO commit):
      "Run: git commit -F .git/gentle_committer_pending_msg
       gentle-ai's gate will review the commit before it lands.
       Do NOT modify the staging area (git add/reset/restore)."
  → the model runs `git commit -F <file>`
  → gentle-ai's bash gate intercepts `git commit` → review → receipt → gate
  → the commit lands ONLY if the review passes
```

**Why indirect?** An extension cannot call the `gentle_review` tool in-process (its execute closure is private; `ExtensionAPI` exposes no `invokeTool`). So gentle-committer prepares the commit and lets the model's `git commit` trip gentle-ai's **existing** gate. Zero coupling — gentle-ai can upgrade without affecting gentle-committer.

**Review-mode guardrails:**

- [ ] Async worker is disabled (review is synchronous; one message file).
- [ ] Grouped/staged commits collapse to a **single** commit (one message, one directive).
- [ ] Automatic triggers (`turn_end` / `tool_result` / `goal_event`) are suppressed — call `commit_changes` explicitly so the directive reaches the model.
- [ ] If `commit_mode = "review"` but gentle-ai isn't loaded, it warns and falls back to `bypass`.

### Per-call mode override

Don't want to flip a global config? Pass `mode` to `commit_changes`:

| You say | Model calls | Result |
| --- | --- | --- |
| "just commit this" | `commit_changes({mode:"bypass"})` | direct commit |
| "review then commit" | `commit_changes({mode:"review"})` | directive → gate |
| (nothing) | `commit_changes()` | uses `commit_mode` config (default `bypass`) |

> **SDD vs review-mode** — SDD reviews the *design/spec* (planning); review-mode gates the *commit diff* (publish). They compose: SDD plan + implement → `commit_changes({mode:"review"})`.

## Configuration

Create `.pi-committer.toml` (or `.pi-committer.json`) in your project root. The extension walks up directories to find it.

```toml
[committer]
enabled           = true
commit_mode       = "bypass"            # bypass | review            (gentle-committer)
prompt_rules_path = "commit-rules/work-unit-commits.md"               # (gentle-committer, optional)
trigger_mode      = "on_goal"           # on_goal | agent_sensible | after_tool | manual
staged_commits    = true
min_changes       = 1
exclude_patterns  = ["*.log", "node_modules/"]

# subagent_model             = "openai/gpt-4o-mini"
# subagent_grouping_min_files = 15   # ≥N files → subagent splits into groups
# subagent_message_min_files  = 3    # <N files → deterministic message (no LLM)
# async_threshold            = 10    # ≥N files → background subprocess; 0 = always sync
# custom_types               = ["api", "wip"]
# allowed_scopes             = ["api", "cli", "core"]
# defer_to_goal_audit        = false
# detailed_body              = true
```

| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `false` | master switch |
| `commit_mode` | `"bypass"` | direct commit vs gentle-ai gate |
| `prompt_rules_path` | *unset* | markdown appended to the commit-message prompts |
| `trigger_mode` | `"on_goal"` | when to auto-commit |
| `staged_commits` | `true` | subagent splits the diff into logical commits |
| `async_threshold` | `10` | ≥ N files → fork into a background subprocess |
| `subagent_model` | agent model | model used to generate messages |
| `exclude_patterns` | `[]` | glob patterns to skip |

### Trigger modes

| Mode | Behaviour |
| --- | --- |
| `on_goal` (default) | commits when a pi-goal transitions to `complete` |
| `agent_sensible` | commits after every agent turn |
| `after_tool` | commits after each tool call |
| `manual` | never auto-commits; `/commit` or `commit_changes` only |

## Work-unit commit rules (`prompt_rules_path`)

Point `prompt_rules_path` at a markdown file (this repo ships `commit-rules/work-unit-commits.md`). Its content is appended to the grouping and single-message prompts — commit-by-work-unit, tests-with-code, scope-equals-work-unit, one-purpose-per-commit. When unset or unreadable, prompts are **byte-identical** to upstream pi-committer (bypass mode is unaffected).

## Features

- **`commit_mode` switch** — direct commit (`bypass`) or gentle-ai-gated commit (`review`).
- **Per-call `mode` override** on `commit_changes` — pick the path per commit.
- Commits automatically when a [pi-goal](https://pi.dev/packages/@capyup/pi-goal) completes (`on_goal` trigger, opt-in).
- Splits diffs into separate commits via a subagent (feature + tests in one commit, docs in another).
- Commit messages generated by a subagent using your model — pick it with `/commit-model` or `subagent_model`.
- Detects and commits in **any** git repositories the agent touched.
- `commit_changes` tool that agents can call to checkpoint work.
- Exclusion patterns for files matching globs (`*.log`, `build/`, etc.).
- Slash commands: `/commit`, `/commit-config`, `/commit-model`.

## Usage

Enable auto-commit, then drive it by goal, command, or tool:

```toml
# .pi-committer.toml
[committer]
enabled = true
```

### Auto-commit on goal completion

With `trigger_mode = "on_goal"` (default), the extension hooks into [pi-goal](https://pi.dev/packages/@capyup/pi-goal)'s lifecycle and commits when a goal transitions to **complete**:

```text
/goals "Add user authentication"   ← start a goal
> agent implements auth logic       ← work happens
> pi-goal marks the goal complete   ← gentle-committer auto-commits
```

### Manual commit

```text
/commit
```

Or ask the agent to "save my progress" — it calls `commit_changes` (pass `{mode:"review"}` if you want it gated).

### Choose the commit-message model

```text
/commit-model
```

Opens an interactive selector. Defaults to your current agent model.

### Reload config

```text
/commit-config
```

## Staged commits

When `staged_commits = true` (default), the subagent receives the diff and organizes changes into commit groups. Editing a source file, adding tests, and updating docs might produce:

```text
feat(api): add user authentication endpoint
test(api): add authentication tests
docs: update API documentation
```

The subagent decides the grouping from the diff content, not from file extensions. *(In `review` mode this is forced to a single commit — one message file, one directive.)*

## Multi-repo

If the agent edits files in multiple git repositories during a session, `commit_changes` finds and commits in all of them. Detection works via session tool-call history — repos where the agent created or modified files using `write` or `edit` are detected on top of the primary working directory.

## Deterministic commit message fallback

When the subagent is unavailable or returns nothing, gentle-committer generates a deterministic message using string analysis — no LLM call:

- **Smart scope** — longest common ancestor directory across changed files; omitted if files span unrelated directories.
- **Specific description** — meaningful keywords from file names (strips extensions, skips `__init__`/`conftest`, converts `snake_case` to words). Never "update N modules".
- **Contamination guard** — `isValidDiffContent` / `isValidDiffStat` / `isValidCommitMessage` reject non-diff output so unrelated shell output can't leak into a message.

Every fallback decision logs a `DIAG:` diagnostic to help identify root causes.

## Architecture

```text
Extension events (turn_end, tool_result, goal_event)  +  commit_changes tool
            │
            ▼
      commitAllRepos(dir, ctx)
            │
            ├─ findDirtyRepos(ctx)          discover all dirty repos
            │
            └─ tryCommit(repo, ...)          for each dirty repo
                   │
                   ├─ stageAll / unstageExcluded / filterGitignored
                   │
                   ├─ resolveEffectiveCommitMode(pi, override?)   bypass | review (+R9 fallback)
                   │
                   ├─ buildGroupingPrompt / buildSingleMessagePrompt   (prompt-builders.ts)
                   │      └─ + loadCommitRules(prompt_rules_path)        append work-unit rules
                   │
                   ├─ generateStagedCommitGroups / generateCommitMessageViaSubagent
                   │      └─ createAgentSession    no tools, diff inline
                   │
                   └─ performCommit(mode, …)        (commit-emission.ts)
                          ├─ bypass → execSync("git commit -F -")   (byte-identical)
                          └─ review → write .git/gentle_committer_pending_msg + handoff directive
```

The commit-emission shim (`commit-emission.ts`) and prompt builders (`prompt-builders.ts`) are shared by both the main process and the async worker, with **no circular imports** (commit-emission uses only node builtins; prompt-builders takes a type-only `CommitterConfig`). The subagent uses `createAgentSession` from `@earendil-works/pi-coding-agent` — no `pi-subagents` dependency required.

## Requirements

- [pi coding agent](https://pi.ai) 0.71+
- Node.js 20+
- Git

## Install from source

```bash
git clone https://github.com/ChaoXu1997/gentle-committer.git
cd gentle-committer && npm install

# try it in a session without installing
pi -e ./index.ts
```

## Development

```bash
cd gentle-committer
npm install

npm test            # unit + worker-edge tests
npm run test:all    # full suite
npm run test:e2e    # end-to-end (PI_COMMITTER_E2E=1)
```

> Tests run via `tsx` (this node build's `--experimental-strip-types` is unreliable); the worker fork resolves `tsx`/`jiti` loaders automatically.

## License

MIT
