@dieulc/workflow 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ Plan↔Build mode with an independent **Review Mode** reviewer, questionnaire, s
12
12
  - **Rewind** — shadow bare-repo checkpoints per cwd (`agent/checkpoints/checkpoint-<slug>.git`), `isDirty` + `createSafetySnapshot` + `restoreCode`
13
13
  - **Plannotator bridge** — `plannotator:request/review-result/plan-approved` events, inline markdown handoff (`ScrollView` → inline scrollback), `getUtcDatePrefix` / `normalizePlanPath` for `.pi/plans/<UTC-date>-<slug>.md`
14
14
  - **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all).
15
+ - **Model profiles** — named combinations of role models (`profiles.ts`, `~/.pi/agent/profiles.json` v1). Create a profile per provider or per budget, switch with `/profile` — activation is session-scoped, so two concurrent sessions can run different profiles. With no profile active the roles.json flow applies unchanged. See [Model Profiles](#model-profiles).
15
16
 
16
17
  ## Install
17
18
 
@@ -34,11 +35,41 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
34
35
  | `/todos` | Show todo list |
35
36
  | `/btw` | Queue note for next turn |
36
37
  | `/role` | Config screen: one model + thinking per role (planner/explorer/builder/reviewer tabs, custom roles included). CLI: `list`, `set <role> <provider/model> [thinking]`, `use <role\|auto>`, `add/remove/reset` |
38
+ | `/profile` | Switch or manage model profiles (named role-model combinations). Picker: activate / edit / rename / delete / New profile… / Default…. CLI: `list`, `show <name>`, `use <name\|none>`, `create <name>`, `edit <name>`, `rename <old> <new>`, `delete <name>`, `default [name\|none] [global\|project]` |
37
39
  | `/review-mode [on\|off] [global\|project]` | Enable/disable Review Mode (default `off`). `status` prints the effective config, naming the settings file that supplied the timeout |
38
40
  | `/review-mode timeout <seconds\|unlimited> [global\|project]` | Set or show the per-pass time cap (`unlimited`/`0` = no cap). Writes to global settings by default |
39
41
  | `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
40
42
  | `/review-status` | Show review phase, model, plan hash, findings and any error |
41
43
  | `/review` | Run one review round against the current plan file now |
44
+ | `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated |
45
+ | `/commit-policy [never\|ask\|allow] [global\|project]` | View or set the commit policy (default `never`: the agent cannot `git add`/`commit`/`push` — you commit, or use `/commit`) |
46
+
47
+ ## Model Profiles
48
+
49
+ A profile is a **named combination of role model settings** — one `{provider, id, thinking}` each for planner, explorer, builder and reviewer. Profiles layer on top of Model Roles: role identity (tools, system prompts, custom roles) stays global in `roles.json`; a profile only overrides models while it is active.
50
+
51
+ ```text
52
+ /profile # picker: activate / edit / rename / delete / New profile… / Default…
53
+ /profile create budget # name it, then pick model + thinking per role (same flow as /role)
54
+ /profile use budget # activate for THIS session (none = back to the roles.json flow)
55
+ /profile edit budget # re-open the tabbed model editor
56
+ /profile rename budget cheap
57
+ /profile delete budget # confirm dialog; deleting the active profile falls back to base roles
58
+ /profile default budget # workflow.defaultProfile — new sessions start on it (global|project)
59
+ ```
60
+
61
+ ### Semantics
62
+
63
+ - **What a profile overrides**: model + thinking for the four built-in roles only. Tools, `systemPromptAddendum` and custom roles are global (roles.json) and are never touched by a profile. Partial profiles are allowed in hand-edited files — roles a profile does not name fall back to the base role model.
64
+ - **Activation is session-scoped.** `/profile use` changes only the current session and rides the session's `workflow` entry, so `/resume` restores it and **two concurrent sessions can run different profiles** — session 1 on a cheap provider, session 2 on a premium one. There is deliberately no global "active profile" file.
65
+ - **Optional default**: `workflow.defaultProfile` (see [Settings](#settings)) seeds sessions that never made an explicit choice; an explicit `/profile use <name|none>` always wins for that session.
66
+ - **Effect**: `/plan` switches to the profile's planner, `/build` to the profile's builder, `explore` subagents run on the profile's explorer, and Review Mode's reviewer session uses the profile's reviewer (planner fallback preserved). Activating while in plan/build re-applies the mode model immediately; in Default mode nothing switches until the next plan/build/explore/review use — the manual chat model is never touched by activation.
67
+ - **`/role` keeps editing the base roles.** With a profile active, the `/role` picker shows a notice and `/role list` prints the profile's overrides; `/profile edit <name>` is the way to change what actually runs.
68
+ - **Fallback**: no profiles (or none active) → the roles.json flow, identical to before profiles existed. A profile referencing a model missing from the registry is warned about at save time and refused at switch time ("staying on current model"), same as `/role`.
69
+
70
+ **Why**: mix models from different providers in one workflow (frontier planner + cheap explorer), keep one profile per provider to control budget per provider, run concurrent sessions on different providers to work around per-provider concurrency limits, and exploit cheaper starter plans across several providers.
71
+
72
+ Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace, project wins). Names are case-insensitively unique, ≤ 40 chars, no whitespace (keep them CLI-addressable), and `none`/`auto` are reserved. A missing file is the normal empty state; a corrupt or wrong-version file is never rewritten (warning + in-memory empty config).
42
73
 
43
74
  ## Review Mode
44
75
 
@@ -234,10 +265,55 @@ A stored credential (`/login <provider>` → `auth.json`) always wins over the `
234
265
 
235
266
  Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global) or `<cwd>/.pi/settings.json` (project). Project values win field-by-field over global. Writing them preserves every other key in the file.
236
267
 
268
+ ## Commit policy
269
+
270
+ By default the agent **cannot touch your git history**: the tool gate deterministically blocks `git add`, `git commit` and `git push` — including compound commands (`git add -A && git commit -m x`), global-option forms (`git -C path add`) and `powershell` wrappers. You commit manually, or run `/commit` for an AI-summarized, confirmation-gated commit (its own `git commit` runs under a one-turn bypass; `push` is never bypassed). Read-only git (`status`, `log`, `diff`, `branch -a`, `config --get`, …) stays free, and plan mode keeps its stricter read-only rules regardless.
271
+
272
+ The default exists because prompt-only rules are soft: models habitually commit per plan step, and "never commit unless asked" instructions in CLAUDE.md-style files are famously ignored (anthropics/claude-code#58883, #68724, #7029). Claude Code, Codex CLI and OpenCode all treat agent-initiated commits as something to gate or approve — so the policy here is enforced where commands execute, not in prose:
273
+
274
+ | Agent | Default commit behavior | Control |
275
+ | --- | --- | --- |
276
+ | Claude Code | never auto-commits | CLAUDE.md rules (soft, documentedly ignored) + PreToolUse hooks (hard) |
277
+ | Codex CLI | approval-gated git mutations | sandbox + `--approval-mode` |
278
+ | OpenCode | no auto-commit | ordered `permission` rules (`git push *` → `deny`), global + project config |
279
+ | Aider | auto-commits every edit | `--no-auto-commits`, `--commit-prompt` (the configurable-commit pole) |
280
+
281
+ ### Modes
282
+
283
+ | Mode | Meaning |
284
+ | --- | --- |
285
+ | `never` (default) | `git add` / `git commit` / `git push` are blocked with a policy reason that tells the model to leave changes uncommitted for review |
286
+ | `ask` | The agent may commit; every `git commit` / `git push` pops an Allow once / Deny dialog (headless sessions fail closed). `git add` stays free — staging alone pollutes nothing |
287
+ | `allow` | **Automatic per-step commits.** While a plan todo list is active the extension itself makes exactly **one commit per completed step** (at the `workflow_todo` completion boundary, before the model starts the next step; subject `step N: <step text>`), and the model cannot run `git add`/`commit`/`push` at all. Outside a plan (no todos) the model may commit as before. `/commit` always works |
288
+
289
+ The status line always shows the active policy (`▶ build 2/5 · commits:never`), so you can see at a glance what the agent may do to history.
290
+
291
+ ```text
292
+ /commit-policy # status: mode, which scope supplied it, both settings paths
293
+ /commit-policy never # set globally (~/.pi/agent/settings.json)
294
+ /commit-policy allow project # this repo only (<cwd>/.pi/settings.json)
295
+ ```
296
+
297
+ Known limits (accepted, same trade-off OpenCode/Claude Code make): a configured git alias (e.g. `ci` → `commit`) is not recognized, and exotic obfuscations (`sh -c 'git commit'`) are caught only when the plain text survives. Plan-mode and subagent read-only gating are unaffected by the policy; the shadow-checkpoint rewind commits live in a separate bare repo and never appear in your history.
298
+
237
299
  ## Settings
238
300
 
239
301
  Role config lives in `~/.pi/agent/roles.json` v2 (user) with optional per-project override at `<cwd>/.pi/roles.json` (per-role replace). Each role holds exactly one `{provider, id, thinking}`. v1 `modelPool` files are discarded and reseeded on load. Seeded with defaults on first run. Respects `theme`, `modelRegistry`, and `plannotator` phases from `agent/plannotator.json`.
240
302
 
303
+ Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace). Each profile holds `{name, description, roles}` where `roles` maps any of the four built-in role names to one `{provider, id, thinking}` — see [Model Profiles](#model-profiles).
304
+
305
+ The default profile lives under `workflow.defaultProfile` (global/project precedence like the other workflow settings):
306
+
307
+ ```json
308
+ {
309
+ "workflow": {
310
+ "defaultProfile": "cheap"
311
+ }
312
+ }
313
+ ```
314
+
315
+ `/profile default <name|none> [global|project]` writes this key read-modify-write, preserving every other settings key. Sessions that never picked explicitly (fresh or resumed) re-read it at every start, so changing the default applies to them; an explicit `/profile use` in a session pins that session until changed.
316
+
241
317
  Review Mode settings live under `workflow.reviewMode`:
242
318
 
243
319
  | Key | Default | Meaning |
@@ -260,9 +336,25 @@ Review Mode settings live under `workflow.reviewMode`:
260
336
 
261
337
  `"reviewMode": true` is accepted as shorthand for `{ "enabled": true }`.
262
338
 
339
+ Commit Policy settings live under `workflow.commitPolicy` (same global/project precedence, project wins field-by-field):
340
+
341
+ | Key | Default | Meaning |
342
+ | --- | --- | --- |
343
+ | `mode` | `"never"` | `"never"` = gate blocks `git add`/`commit`/`push`; `"ask"` = each `git commit`/`push` asks first; `"allow"` = automatic per-step commits (extension-driven, one per completed step; the model cannot commit during a plan) |
344
+
345
+ ```json
346
+ {
347
+ "workflow": {
348
+ "commitPolicy": { "mode": "ask" }
349
+ }
350
+ }
351
+ ```
352
+
353
+ `"commitPolicy": "ask"` (a bare mode string) is accepted as shorthand for `{ "mode": "ask" }`. Booleans, numbers and unknown modes fall back to the default. `/commit-policy` writes this key read-modify-write, preserving every other settings key.
354
+
263
355
  ## How it works
264
356
 
265
- See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
357
+ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit policy config, coercion and write — no runtime imports, unit-testable), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), `profiles.ts` (profile registry, the model overlay and the `workflow.defaultProfile` setting — no Pi runtime needed, unit-testable), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
266
358
 
267
359
  ### Todo protocol (industry-aligned)
268
360
 
@@ -306,7 +398,7 @@ What to do:
306
398
  cd agent/extensions/workflow
307
399
  npm install
308
400
  npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
309
- npm test # node --test utils.todo.test.ts review.test.ts review-ui.test.ts
401
+ npm test # node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts
310
402
  ```
311
403
 
312
404
  Plain TypeScript, no build step — Pi loads `index.ts` directly (via jiti). From the repo root, `npm run verify` checks this package's manifest, tarball contents, tests and load path before a release. Live reload: edit the `.ts` file and run `/reload` in Pi.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Workflow Extension — Commit Policy
3
+ *
4
+ * User-controllable policy for agent-initiated git commits:
5
+ *
6
+ * "never" (default) — the agent may NOT run `git add` / `git commit` /
7
+ * `git push`. The deterministic tool_call gate blocks them; the
8
+ * user commits manually or via /commit (which uses a bypass).
9
+ * "ask" — every `git commit` / `git push` attempt pops a confirmation
10
+ * dialog (`git add` stays free — staging alone pollutes nothing).
11
+ * "allow" — the agent may commit freely; during plan execution the
12
+ * handoff prompt instructs per-step commits (user opted in).
13
+ *
14
+ * This module holds the pure, I/O-light parts (config, coercion, merge,
15
+ * read, write) so they can be unit-tested without a Pi runtime. Nothing
16
+ * here imports the Pi SDK at module scope — same discipline as review.ts.
17
+ *
18
+ * Settings live under `workflow.commitPolicy` in `~/.pi/agent/settings.json`
19
+ * (global) or `<cwd>/.pi/settings.json` (project). Project wins field-by-field
20
+ * over global; global wins over defaults. Writes preserve every other key in
21
+ * the file (Pi owns settings.json — we must not clobber it).
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+
27
+ /** Pi's project config directory. Mirrors WORKFLOW_CONFIG_DIR without importing review.ts (which pulls provider modules). */
28
+ export const COMMIT_CONFIG_DIR = ".pi";
29
+
30
+ /** Top-level key in settings.json that namespaces all workflow settings. Mirrors WORKFLOW_SETTINGS_KEY. */
31
+ export const WORKFLOW_SETTINGS_KEY = "workflow";
32
+
33
+ /** Nested key under WORKFLOW_SETTINGS_KEY. */
34
+ export const COMMIT_POLICY_KEY = "commitPolicy";
35
+
36
+ /** How the agent may interact with git's staging/commit/push surface. */
37
+ export type CommitPolicyMode = "never" | "ask" | "allow";
38
+
39
+ export interface CommitPolicyConfig {
40
+ mode: CommitPolicyMode;
41
+ }
42
+
43
+ export const DEFAULT_COMMIT_POLICY_CONFIG: Readonly<CommitPolicyConfig> = {
44
+ mode: "never",
45
+ };
46
+
47
+ const COMMIT_POLICY_MODES: readonly CommitPolicyMode[] = [
48
+ "never",
49
+ "ask",
50
+ "allow",
51
+ ];
52
+
53
+ /** A JSON object parsed from an untrusted source (settings file, fragment). */
54
+ export type JsonObject = Record<string, unknown>;
55
+
56
+ /**
57
+ * A raw `workflow.commitPolicy` fragment. A bare mode string ("never" |
58
+ * "ask" | "allow") is accepted as shorthand for `{ mode: <string> }` so a
59
+ * minimal settings file stays readable.
60
+ */
61
+ export type RawCommitPolicySetting = JsonObject | string;
62
+
63
+ /** Coerce an unknown value into a CommitPolicyMode, or undefined. */
64
+ function coerceMode(value: unknown): CommitPolicyMode | undefined {
65
+ if (typeof value !== "string") return undefined;
66
+ const normalized = value.trim().toLowerCase();
67
+ return (COMMIT_POLICY_MODES as readonly string[]).includes(normalized)
68
+ ? (normalized as CommitPolicyMode)
69
+ : undefined;
70
+ }
71
+
72
+ /**
73
+ * Coerce an untrusted settings fragment into a complete CommitPolicyConfig.
74
+ * Accepts an object (`{ mode: "never" }`) or a bare mode string shorthand.
75
+ * Booleans, numbers, garbage and wrong types all fall back to `base`
76
+ * (defaults) — there is no boolean shorthand for commit policy. Never throws.
77
+ */
78
+ export function coerceCommitPolicyConfig(
79
+ raw: unknown,
80
+ base: CommitPolicyConfig = DEFAULT_COMMIT_POLICY_CONFIG as CommitPolicyConfig,
81
+ ): CommitPolicyConfig {
82
+ if (typeof raw === "string") {
83
+ const mode = coerceMode(raw);
84
+ return mode ? { mode } : { ...base };
85
+ }
86
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
87
+ return { ...base };
88
+ }
89
+ const mode = coerceMode((raw as JsonObject).mode);
90
+ return { mode: mode ?? base.mode };
91
+ }
92
+
93
+ /** Extract the `workflow.commitPolicy` fragment from a raw settings object. */
94
+ export function extractCommitPolicyRaw(
95
+ settings: unknown,
96
+ ): RawCommitPolicySetting | undefined {
97
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
98
+ return undefined;
99
+ const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
100
+ if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
101
+ const raw = (wf as JsonObject)[COMMIT_POLICY_KEY];
102
+ if (typeof raw === "string") return raw;
103
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
104
+ return raw as JsonObject;
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Merge global then project settings fragments, then coerce onto defaults.
110
+ * Project wins field-by-field over global; global wins over defaults.
111
+ */
112
+ export function mergeCommitPolicyConfig(
113
+ globalRaw: unknown,
114
+ projectRaw: unknown,
115
+ ): CommitPolicyConfig {
116
+ const fromGlobal = coerceCommitPolicyConfig(globalRaw);
117
+ return coerceCommitPolicyConfig(projectRaw, fromGlobal);
118
+ }
119
+
120
+ export interface CommitConfigPaths {
121
+ global: string;
122
+ project: string;
123
+ }
124
+
125
+ /**
126
+ * Settings file locations. `agentDir` is passed in rather than read from the
127
+ * Pi SDK so this stays pure and testable.
128
+ */
129
+ export function commitConfigPaths(
130
+ cwd: string,
131
+ agentDir: string,
132
+ ): CommitConfigPaths {
133
+ return {
134
+ global: path.join(agentDir, "settings.json"),
135
+ project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
136
+ };
137
+ }
138
+
139
+ function readJsonSafe(fp: string): JsonObject | undefined {
140
+ try {
141
+ if (!fs.existsSync(fp)) return undefined;
142
+ const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
143
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
144
+ return undefined;
145
+ return parsed as JsonObject;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ /** Which settings scope supplied a field's effective value. */
152
+ export type CommitPolicyScopeSource = "default" | "global" | "project";
153
+
154
+ /**
155
+ * Which raw fragment supplied the effective `mode`: the project fragment when
156
+ * it carries the key, else the global fragment, else the built-in default.
157
+ * Pure and total.
158
+ */
159
+ export function modeSourceOf(
160
+ globalRaw: RawCommitPolicySetting | undefined,
161
+ projectRaw: RawCommitPolicySetting | undefined,
162
+ ): CommitPolicyScopeSource {
163
+ const hasModeKey = (raw: unknown): boolean =>
164
+ typeof raw === "string"
165
+ ? coerceMode(raw) !== undefined
166
+ : Boolean(raw) &&
167
+ typeof raw === "object" &&
168
+ !Array.isArray(raw) &&
169
+ coerceMode((raw as JsonObject).mode) !== undefined;
170
+ if (hasModeKey(projectRaw)) return "project";
171
+ if (hasModeKey(globalRaw)) return "global";
172
+ return "default";
173
+ }
174
+
175
+ export interface ResolvedCommitPolicy {
176
+ config: CommitPolicyConfig;
177
+ /** Which files contributed (for /commit-policy status and debugging). */
178
+ sources: {
179
+ global: boolean;
180
+ project: boolean;
181
+ /** Which scope supplied the effective `mode` value. */
182
+ mode: CommitPolicyScopeSource;
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Resolve the effective Commit Policy config from disk.
188
+ * Never throws — a corrupt or missing file falls back to defaults.
189
+ */
190
+ export function readCommitPolicyConfig(
191
+ cwd: string,
192
+ agentDir: string,
193
+ ): ResolvedCommitPolicy {
194
+ const paths = commitConfigPaths(cwd, agentDir);
195
+ const globalSettings = readJsonSafe(paths.global);
196
+ const projectSettings = readJsonSafe(paths.project);
197
+ const globalRaw = extractCommitPolicyRaw(globalSettings);
198
+ const projectRaw = extractCommitPolicyRaw(projectSettings);
199
+ const config = mergeCommitPolicyConfig(globalRaw, projectRaw);
200
+ return {
201
+ config,
202
+ sources: {
203
+ global: globalRaw !== undefined,
204
+ project: projectRaw !== undefined,
205
+ mode: modeSourceOf(globalRaw, projectRaw),
206
+ },
207
+ };
208
+ }
209
+
210
+ /** One-line human-readable summary of a policy mode (for status output). */
211
+ export function describeCommitPolicyMode(mode: CommitPolicyMode): string {
212
+ switch (mode) {
213
+ case "never":
214
+ return "agent cannot git add/commit/push — you commit (or /commit)";
215
+ case "ask":
216
+ return "agent may commit; each git commit/push asks first";
217
+ case "allow":
218
+ return "automatic: exactly one commit per completed plan step — the model itself never runs git commit";
219
+ default:
220
+ return "agent cannot git add/commit/push — you commit (or /commit)";
221
+ }
222
+ }
223
+
224
+ // ── Deterministic per-step commits (allow mode) ─────────────────────
225
+
226
+ /** A completed plan step that may be due for its automatic commit. */
227
+ export interface StepCommitCandidate {
228
+ /** Canonical global ref ("1", "2", …). */
229
+ ref: string;
230
+ /** Global step number when known (preferred for the message). */
231
+ step?: number;
232
+ /** Plan label fallback (e.g. "2.1"). */
233
+ label?: string;
234
+ /** Step text — becomes the commit subject. */
235
+ text: string;
236
+ }
237
+
238
+ export interface StepCommitDecisionInput {
239
+ mode: CommitPolicyMode;
240
+ /** A plan todo list is active (per-step automation only makes sense then). */
241
+ hasTodos: boolean;
242
+ /** Working tree has changes worth committing. */
243
+ dirty: boolean;
244
+ isInRepo: boolean;
245
+ /** Done steps not yet committed (already diffed against committed refs). */
246
+ completed: StepCommitCandidate[];
247
+ }
248
+
249
+ export interface StepCommitDecision {
250
+ commit: boolean;
251
+ refs: string[];
252
+ message: string;
253
+ /** Why no commit happens (for logs and notifications). */
254
+ skipReason?:
255
+ | "mode"
256
+ | "no-todos"
257
+ | "clean"
258
+ | "no-repo"
259
+ | "nothing-new";
260
+ }
261
+
262
+ /** Git subject lines should stay ≤ 72 chars. */
263
+ const STEP_COMMIT_SUBJECT_MAX = 72;
264
+
265
+ /**
266
+ * Build the deterministic commit subject for one completed step (or one
267
+ * batch): `step 3: implement parser` / `steps 2,3: first text (+1 more)`.
268
+ * Pure and total — the same steps always produce the same message.
269
+ */
270
+ export function stepCommitMessage(items: StepCommitCandidate[]): string {
271
+ const first = items[0];
272
+ const text = String(first?.text ?? "").replace(/\s+/g, " ").trim();
273
+ if (items.length === 1) {
274
+ const num = first?.step ?? Number(first?.label) ?? 0;
275
+ const prefix = `step ${num > 0 ? num : (first?.ref ?? "?")}: `;
276
+ const room = STEP_COMMIT_SUBJECT_MAX - prefix.length;
277
+ const body = text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text;
278
+ return `${prefix}${body}`.slice(0, STEP_COMMIT_SUBJECT_MAX);
279
+ }
280
+ const nums = items
281
+ .map((i) => i.step ?? Number(i.label) ?? 0)
282
+ .filter((n) => n > 0);
283
+ const label = nums.length ? `steps ${nums.join(",")}` : "steps";
284
+ const suffix = ` (+${items.length - 1} more)`;
285
+ const room = STEP_COMMIT_SUBJECT_MAX - label.length - 2 - suffix.length;
286
+ const body = text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text;
287
+ return `${label}: ${body}${suffix}`.slice(0, STEP_COMMIT_SUBJECT_MAX);
288
+ }
289
+
290
+ /**
291
+ * Decide whether the extension itself should commit right now — the core of
292
+ * the per-step guarantee in `allow` mode. Exactly one commit per batch of
293
+ * newly completed steps; the CALLER owns idempotence by never passing a step
294
+ * twice (see committedStepRefs in index.ts). Never throws.
295
+ */
296
+ export function stepCommitDecision(
297
+ input: StepCommitDecisionInput,
298
+ ): StepCommitDecision {
299
+ const none = (skipReason: NonNullable<StepCommitDecision["skipReason"]>): StepCommitDecision => ({
300
+ commit: false,
301
+ refs: [],
302
+ message: "",
303
+ skipReason,
304
+ });
305
+ if (!input?.isInRepo) return none("no-repo");
306
+ if (input.mode !== "allow") return none("mode");
307
+ if (!input.hasTodos) return none("no-todos");
308
+ const seen = new Set<string>();
309
+ const completed = (input.completed ?? []).filter((c) => {
310
+ if (!c || typeof c.ref !== "string" || !c.ref) return false;
311
+ if (seen.has(c.ref)) return false;
312
+ seen.add(c.ref);
313
+ return true;
314
+ });
315
+ if (completed.length === 0) return none("nothing-new");
316
+ if (!input.dirty) return none("clean");
317
+ return {
318
+ commit: true,
319
+ refs: completed.map((c) => c.ref),
320
+ message: stepCommitMessage(completed),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Read-modify-write `workflow.commitPolicy` into a settings file, preserving
326
+ * every other key in that file (Pi owns settings.json — we must not clobber it).
327
+ */
328
+ export function writeCommitPolicyConfig(
329
+ scope: "global" | "project",
330
+ cwd: string,
331
+ agentDir: string,
332
+ patch: Partial<CommitPolicyConfig>,
333
+ ): { path: string; ok: boolean; error?: string } {
334
+ const paths = commitConfigPaths(cwd, agentDir);
335
+ const fp = scope === "project" ? paths.project : paths.global;
336
+ try {
337
+ const existing = readJsonSafe(fp);
338
+ const root: JsonObject = existing ? { ...existing } : {};
339
+ const wfExisting = root[WORKFLOW_SETTINGS_KEY];
340
+ const wf: JsonObject =
341
+ wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
342
+ ? { ...(wfExisting as JsonObject) }
343
+ : {};
344
+ const current = coerceCommitPolicyConfig(wf[COMMIT_POLICY_KEY]);
345
+ const next = coerceCommitPolicyConfig({ ...current, ...patch }, current);
346
+ wf[COMMIT_POLICY_KEY] = next;
347
+ root[WORKFLOW_SETTINGS_KEY] = wf;
348
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
349
+ fs.writeFileSync(fp, JSON.stringify(root, null, 2) + "\n", "utf8");
350
+ return { path: fp, ok: true };
351
+ } catch (e: unknown) {
352
+ return {
353
+ path: fp,
354
+ ok: false,
355
+ error: e instanceof Error ? e.message : String(e),
356
+ };
357
+ }
358
+ }