@dieulc/workflow 0.3.3 → 0.4.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
@@ -39,6 +39,8 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
39
39
  | `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
40
40
  | `/review-status` | Show review phase, model, plan hash, findings and any error |
41
41
  | `/review` | Run one review round against the current plan file now |
42
+ | `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated |
43
+ | `/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`) |
42
44
 
43
45
  ## Review Mode
44
46
 
@@ -234,6 +236,37 @@ A stored credential (`/login <provider>` → `auth.json`) always wins over the `
234
236
 
235
237
  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
238
 
239
+ ## Commit policy
240
+
241
+ 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.
242
+
243
+ 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:
244
+
245
+ | Agent | Default commit behavior | Control |
246
+ | --- | --- | --- |
247
+ | Claude Code | never auto-commits | CLAUDE.md rules (soft, documentedly ignored) + PreToolUse hooks (hard) |
248
+ | Codex CLI | approval-gated git mutations | sandbox + `--approval-mode` |
249
+ | OpenCode | no auto-commit | ordered `permission` rules (`git push *` → `deny`), global + project config |
250
+ | Aider | auto-commits every edit | `--no-auto-commits`, `--commit-prompt` (the configurable-commit pole) |
251
+
252
+ ### Modes
253
+
254
+ | Mode | Meaning |
255
+ | --- | --- |
256
+ | `never` (default) | `git add` / `git commit` / `git push` are blocked with a policy reason that tells the model to leave changes uncommitted for review |
257
+ | `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 |
258
+ | `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 |
259
+
260
+ 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.
261
+
262
+ ```text
263
+ /commit-policy # status: mode, which scope supplied it, both settings paths
264
+ /commit-policy never # set globally (~/.pi/agent/settings.json)
265
+ /commit-policy allow project # this repo only (<cwd>/.pi/settings.json)
266
+ ```
267
+
268
+ 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.
269
+
237
270
  ## Settings
238
271
 
239
272
  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`.
@@ -260,9 +293,25 @@ Review Mode settings live under `workflow.reviewMode`:
260
293
 
261
294
  `"reviewMode": true` is accepted as shorthand for `{ "enabled": true }`.
262
295
 
296
+ Commit Policy settings live under `workflow.commitPolicy` (same global/project precedence, project wins field-by-field):
297
+
298
+ | Key | Default | Meaning |
299
+ | --- | --- | --- |
300
+ | `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) |
301
+
302
+ ```json
303
+ {
304
+ "workflow": {
305
+ "commitPolicy": { "mode": "ask" }
306
+ }
307
+ }
308
+ ```
309
+
310
+ `"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.
311
+
263
312
  ## How it works
264
313
 
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.
314
+ 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), 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
315
 
267
316
  ### Todo protocol (industry-aligned)
268
317
 
@@ -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
+ }
package/index.ts CHANGED
@@ -82,11 +82,21 @@ import {
82
82
  import {
83
83
  WORKFLOW_ROLE_ENV,
84
84
  explainCommandRefusal,
85
+ gitCommitPolicyHit,
85
86
  policyForRole,
86
87
  resolveSessionRole,
87
88
  subagentEnv,
88
89
  type WorkflowRole,
89
90
  } from "./permissions.ts";
91
+ import {
92
+ commitConfigPaths,
93
+ describeCommitPolicyMode,
94
+ readCommitPolicyConfig,
95
+ stepCommitDecision,
96
+ writeCommitPolicyConfig,
97
+ type CommitPolicyMode,
98
+ type StepCommitCandidate,
99
+ } from "./commit-policy.ts";
90
100
  import {
91
101
  isGitRepo,
92
102
  isGitRepoSync,
@@ -372,6 +382,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
372
382
  let todoItems: TodoItem[] = [];
373
383
  // Plan file the current todo list was derived from (drives merge vs replace).
374
384
  let todoPlanPath: string | undefined;
385
+ // Armed while the user-invoked /commit flow's agent turn is running: its
386
+ // `git commit` bypasses the commit-policy gate (staging already happened
387
+ // extension-side via piExecGit, outside the tool gate). Cleared on
388
+ // turn_end/agent_settled so the window stays one turn wide.
389
+ let commitBypassActive = false;
390
+ // ── Deterministic per-step commits (allow mode) ──
391
+ // In 'allow' mode the EXTENSION — not the model — owns commit timing:
392
+ // exactly one commit per batch of newly completed plan steps, made at the
393
+ // workflow_todo tool_result boundary (the runner awaits tool_result
394
+ // handlers, so the commit lands before the model starts the next step).
395
+ // committedStepRefs makes the guarantee idempotent; stepCommitsSeeded
396
+ // marks pre-existing done steps (session resume, re-executed plan) as
397
+ // already accounted for so they never trigger a spurious commit.
398
+ const committedStepRefs = new Set<string>();
399
+ let stepCommitsSeeded = false;
375
400
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
376
401
  let turnsSinceLastTodoWrite = 0;
377
402
  let turnsSinceLastReminder = 0;
@@ -600,14 +625,27 @@ export default function workflowExtension(pi: ExtensionAPI) {
600
625
  function updateStatus(ctx: ExtensionContext) {
601
626
  const roleLabel = activeRoleLabel();
602
627
  const suffix = roleLabel ? ` | ${roleLabel}` : "";
628
+ // Commit-policy surfacing (workflow.commitPolicy): always visible so the
629
+ // user can see at a glance what the agent may do to git history. Read
630
+ // live so /commit-policy changes show up without a reload.
631
+ let commitMode: CommitPolicyMode = "never";
632
+ try {
633
+ commitMode = readCommitPolicyConfig(
634
+ (ctx as any).cwd ?? process.cwd(),
635
+ getAgentDir(),
636
+ ).config.mode;
637
+ } catch {
638
+ commitMode = "never";
639
+ }
640
+ const policy = ` · commits:${commitMode}`;
603
641
  const staleMarker = todoStale ? " ⚠" : "";
604
642
  const done = todoItems.filter((t) => isDone(t)).length;
605
643
  const total = todoItems.length;
606
644
  if (workflowMode === "build") {
607
645
  const label =
608
646
  total > 0
609
- ? `▶ build ${done}/${total}${staleMarker}${suffix}`
610
- : `▶ build${suffix}`;
647
+ ? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
648
+ : `▶ build${suffix}${policy}`;
611
649
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
612
650
  } else if (workflowMode === "plan") {
613
651
  const base = plannotatorActive
@@ -615,16 +653,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
615
653
  : "⏸ plan";
616
654
  const label =
617
655
  total > 0
618
- ? `${base} ${done}/${total}${staleMarker}${suffix}`
619
- : `${base}${suffix}`;
656
+ ? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
657
+ : `${base}${suffix}${policy}`;
620
658
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
621
659
  } else if (total > 0) {
622
660
  ctx.ui.setStatus(
623
661
  "workflow",
624
- ctx.ui.theme.fg("muted", `◦ ${done}/${total}${staleMarker}${suffix}`),
662
+ ctx.ui.theme.fg(
663
+ "muted",
664
+ `◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
665
+ ),
625
666
  );
626
667
  } else {
627
- ctx.ui.setStatus("workflow", undefined);
668
+ ctx.ui.setStatus(
669
+ "workflow",
670
+ ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
671
+ );
628
672
  }
629
673
  if (todoItems.length > 0) {
630
674
  ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
@@ -1200,6 +1244,112 @@ export default function workflowExtension(pi: ExtensionAPI) {
1200
1244
  },
1201
1245
  });
1202
1246
 
1247
+ // ── /commit-policy: view/set the commit policy (workflow.commitPolicy) ──
1248
+ // Available in every mode: it only writes settings JSON; the gate itself
1249
+ // lives in tool_call and plan mode keeps its own stricter rules.
1250
+ pi.registerCommand("commit-policy", {
1251
+ description:
1252
+ "View or set the commit policy (what the agent may git add/commit/push): /commit-policy never|ask|allow [global|project]",
1253
+ handler: async (args, ctx) => {
1254
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
1255
+ const agentDir = getAgentDir();
1256
+ const tokens = String(args || "")
1257
+ .trim()
1258
+ .split(/\s+/)
1259
+ .filter(Boolean);
1260
+ const sub = (tokens[0] || "").toLowerCase();
1261
+ const scope: "global" | "project" =
1262
+ tokens.includes("project") && !tokens.includes("global")
1263
+ ? "project"
1264
+ : "global";
1265
+
1266
+ if (!sub || sub === "status") {
1267
+ const resolved = readCommitPolicyConfig(cwd, agentDir);
1268
+ const paths = commitConfigPaths(cwd, agentDir);
1269
+ const where =
1270
+ resolved.sources.mode === "default"
1271
+ ? "default — no settings file supplies it"
1272
+ : `${resolved.sources.mode} settings`;
1273
+ ctx.ui.notify(
1274
+ [
1275
+ `Commit policy: ${resolved.config.mode} (${where})`,
1276
+ ` ${describeCommitPolicyMode(resolved.config.mode)}`,
1277
+ `Global settings: ${paths.global}`,
1278
+ `Project settings: ${paths.project}`,
1279
+ "Usage: /commit-policy never|ask|allow [global|project]",
1280
+ ].join("\n"),
1281
+ "info",
1282
+ );
1283
+ updateStatus(ctx as any);
1284
+ return;
1285
+ }
1286
+
1287
+ if (sub !== "never" && sub !== "ask" && sub !== "allow") {
1288
+ ctx.ui.notify(
1289
+ [
1290
+ "Usage: /commit-policy never|ask|allow [global|project]",
1291
+ " never agent cannot git add/commit/push — you commit (or /commit)",
1292
+ " ask agent may commit; each git commit/push asks first",
1293
+ " allow agent may commit freely (per-step during plan execution)",
1294
+ ].join("\n"),
1295
+ "info",
1296
+ );
1297
+ return;
1298
+ }
1299
+
1300
+ const res = writeCommitPolicyConfig(scope, cwd, agentDir, {
1301
+ mode: sub as CommitPolicyMode,
1302
+ });
1303
+ if (!res.ok) {
1304
+ ctx.ui.notify(
1305
+ `Could not write commit-policy settings: ${res.error ?? "unknown error"}`,
1306
+ "error",
1307
+ );
1308
+ return;
1309
+ }
1310
+ ctx.ui.notify(
1311
+ `Commit policy → ${sub} (${scope})\n${describeCommitPolicyMode(
1312
+ sub as CommitPolicyMode,
1313
+ )}\nSettings: ${res.path}`,
1314
+ "info",
1315
+ );
1316
+ updateStatus(ctx as any);
1317
+ },
1318
+ getArgumentCompletions: (prefix: string) => {
1319
+ const flags = [
1320
+ {
1321
+ value: "never",
1322
+ label: "never",
1323
+ description:
1324
+ "agent cannot git add/commit/push — you commit (or /commit)",
1325
+ },
1326
+ {
1327
+ value: "ask",
1328
+ label: "ask",
1329
+ description: "agent may commit; each git commit/push asks first",
1330
+ },
1331
+ {
1332
+ value: "allow",
1333
+ label: "allow",
1334
+ description:
1335
+ "agent may commit freely (per-step during plan execution)",
1336
+ },
1337
+ {
1338
+ value: "global",
1339
+ label: "global",
1340
+ description: "write to ~/.pi/agent/settings.json",
1341
+ },
1342
+ {
1343
+ value: "project",
1344
+ label: "project",
1345
+ description: "write to <cwd>/.pi/settings.json",
1346
+ },
1347
+ ];
1348
+ const filtered = flags.filter((f) => f.value.startsWith(prefix));
1349
+ return filtered.length ? filtered : null;
1350
+ },
1351
+ });
1352
+
1203
1353
  pi.registerCommand("review-prompt", {
1204
1354
  description:
1205
1355
  "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
@@ -1845,7 +1995,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1845
1995
  };
1846
1996
  const firstTodo = todoItems[0];
1847
1997
  const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
1848
- const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
1998
+ const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
1849
1999
  (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
1850
2000
  (pi as any).sendMessage?.(
1851
2001
  {
@@ -2121,6 +2271,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
2121
2271
 
2122
2272
  // Inject a user message → triggers an agent turn that proposes + confirms + commits
2123
2273
  const message = buildCommitPrompt(statSummary, stageAll);
2274
+ // Arm the commit-policy bypass for exactly this agent turn: its `git
2275
+ // commit` must run even under policy 'never'. Disarmed on turn_end /
2276
+ // agent_settled, so the window is one turn wide.
2277
+ commitBypassActive = true;
2124
2278
  if ((pi as any).sendUserMessage) {
2125
2279
  (pi as any).sendUserMessage(message);
2126
2280
  } else {
@@ -4594,6 +4748,118 @@ export default function workflowExtension(pi: ExtensionAPI) {
4594
4748
  updateStatus(ctx as any);
4595
4749
  });
4596
4750
 
4751
+ // ── Commit policy gate (workflow.commitPolicy) ──────────────────
4752
+
4753
+ /**
4754
+ * Deterministic enforcement of the user's commit policy. Returns a block
4755
+ * result for `git add` / `git commit` / `git push` per the active mode, or
4756
+ * undefined when the command may proceed:
4757
+ * never → block; ask → confirmation overlay (headless: fail closed);
4758
+ * allow → automatic per-step commits while a plan todo list is active
4759
+ * (the extension owns commit timing — model-initiated git add/commit/push
4760
+ * are blocked); outside a plan the model may commit as before. The
4761
+ * user-invoked /commit flow arms `commitBypassActive` so its own `git
4762
+ * commit` runs (staging is extension-side; `push` and `add` are never
4763
+ * bypassed).
4764
+ */
4765
+ async function commitPolicyGate(
4766
+ event: any,
4767
+ ctx: any,
4768
+ cwd: string,
4769
+ ): Promise<{ block: true; reason: string } | undefined> {
4770
+ const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
4771
+ // allow outside a plan: no per-step automation to own commit timing, so
4772
+ // the model keeps its previous freedom.
4773
+ if (policy === "allow" && todoItems.length === 0) return undefined;
4774
+ const cmd = String(event?.input?.command ?? "");
4775
+ const hit = gitCommitPolicyHit(cmd);
4776
+ if (!hit) return undefined;
4777
+ // `add` is only gated in `never` mode; `ask` prompts on commit/push only
4778
+ // (staging alone pollutes nothing).
4779
+ if (policy === "ask" && hit === "add") return undefined;
4780
+ // /commit bypass: only the flow's own `git commit` is exempt — `add` and
4781
+ // `push` are not.
4782
+ if (commitBypassActive && hit === "commit") {
4783
+ return undefined;
4784
+ }
4785
+
4786
+ if (policy === "never" || policy === "allow") {
4787
+ return {
4788
+ block: true,
4789
+ reason:
4790
+ policy === "never"
4791
+ ? `Commit policy is 'never' (workflow.commitPolicy): \`git ${hit}\` is blocked. ` +
4792
+ "Finish your steps and leave changes uncommitted for user review — the user commits manually or runs /commit. " +
4793
+ `Change the policy with /commit-policy allow.\nCommand: ${cmd}`
4794
+ : `Commit policy is 'allow' with an active plan: a commit is made AUTOMATICALLY after each completed step — do not run \`git ${hit}\` yourself. ` +
4795
+ "Finish the step, mark it done via workflow_todo, and continue; the extension commits it (exactly one commit per step). " +
4796
+ "/commit always works for user-invoked commits.\nCommand: " +
4797
+ cmd,
4798
+ };
4799
+ }
4800
+ // policy === "ask" → confirmation overlay. Headless has no one to ask →
4801
+ // fail closed rather than commit unbidden.
4802
+ const ui = ctx?.ui;
4803
+ if (!ui || !ctx.hasUI) {
4804
+ return {
4805
+ block: true,
4806
+ reason:
4807
+ `Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
4808
+ "but no interactive UI is available. Leave the change uncommitted and let the user decide.",
4809
+ };
4810
+ }
4811
+ let sel: unknown;
4812
+ try {
4813
+ sel = await ui.select(
4814
+ `Commit policy 'ask': allow \`git ${hit}\`?`,
4815
+ ["Allow once", "Deny"],
4816
+ );
4817
+ } catch {
4818
+ sel = undefined;
4819
+ }
4820
+ if (String(sel ?? "").startsWith("Allow")) return undefined;
4821
+ return {
4822
+ block: true,
4823
+ reason:
4824
+ `Commit policy 'ask': the user declined \`git ${hit}\`. Do not retry — ` +
4825
+ "leave the changes uncommitted for the user.",
4826
+ };
4827
+ }
4828
+
4829
+ /**
4830
+ * Policy-aware commit directive appended to the plan-execute handoff
4831
+ * message. Empty string when the policy cannot be read (a config hiccup
4832
+ * never blocks the handoff itself).
4833
+ */
4834
+ function commitDirectiveLine(ctx: any): string {
4835
+ try {
4836
+ const cwd = ctx?.cwd ?? process.cwd();
4837
+ const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
4838
+ switch (mode) {
4839
+ case "never":
4840
+ return (
4841
+ "\n\nCommit policy is 'never' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push`. " +
4842
+ "Complete all steps and leave every change uncommitted for user review — the user commits manually or via /commit."
4843
+ );
4844
+ case "ask":
4845
+ return (
4846
+ "\n\nCommit policy is 'ask' (workflow.commitPolicy): you may commit after each completed step " +
4847
+ "(`git add` + `git commit` with a concise conventional message); each `git commit`/`git push` will prompt the user for approval."
4848
+ );
4849
+ case "allow":
4850
+ return (
4851
+ "\n\nCommit policy is 'allow' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push` yourself — " +
4852
+ "after each completed step the extension automatically makes exactly ONE commit containing the changes so far. " +
4853
+ "Just finish a step, mark it done via workflow_todo, and move to the next step."
4854
+ );
4855
+ default:
4856
+ return "";
4857
+ }
4858
+ } catch {
4859
+ return "";
4860
+ }
4861
+ }
4862
+
4597
4863
  // Block edits/writes + bash gating
4598
4864
  pi.on("tool_call", async (event, ctx) => {
4599
4865
  const cwd = (ctx as ExtensionContext).cwd;
@@ -4652,6 +4918,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
4652
4918
  } as any;
4653
4919
  }
4654
4920
  }
4921
+ // ── Commit policy gate ── Independent of the role gate: it also governs
4922
+ // the unrestricted builder role — that is the point (user-controlled
4923
+ // commit behavior). Plan mode is excluded: planner gating already blocks
4924
+ // every mutating git command. Covers bash and powershell so a powershell
4925
+ // wrapper cannot bypass the policy.
4926
+ if (
4927
+ (event.toolName === "bash" || event.toolName === "powershell") &&
4928
+ workflowMode !== "plan"
4929
+ ) {
4930
+ const policyBlock = await commitPolicyGate(event, ctx, cwd);
4931
+ if (policyBlock) return policyBlock as any;
4932
+ }
4655
4933
  if (
4656
4934
  (event.toolName === "edit" || event.toolName === "write") &&
4657
4935
  workflowMode === "plan"
@@ -4735,6 +5013,104 @@ export default function workflowExtension(pi: ExtensionAPI) {
4735
5013
  });
4736
5014
 
4737
5015
  // ── Checkpoint lifecycle: track leaf entryId and prompt header ─
5016
+ // ── Deterministic per-step commit executor (allow mode) ──────────
5017
+
5018
+ /** Mark pre-existing done steps (session resume, re-executed plan) as already accounted for. Called once per session. */
5019
+ function seedCommittedStepRefs(): void {
5020
+ if (stepCommitsSeeded) return;
5021
+ stepCommitsSeeded = true;
5022
+ for (const t of todoItems) {
5023
+ if (!isDone(t) || itemStatus(t) === "cancelled") continue;
5024
+ committedStepRefs.add(todoRef(t));
5025
+ }
5026
+ }
5027
+
5028
+ /** Done steps that have not been committed yet (in canonical order). */
5029
+ function uncommittedDoneSteps(): StepCommitCandidate[] {
5030
+ const out: StepCommitCandidate[] = [];
5031
+ for (const t of todoItems) {
5032
+ if (!isDone(t) || itemStatus(t) === "cancelled") continue;
5033
+ const ref = todoRef(t);
5034
+ if (committedStepRefs.has(ref)) continue;
5035
+ out.push({ ref, step: t.step, label: t.label, text: t.text });
5036
+ }
5037
+ return out;
5038
+ }
5039
+
5040
+ /**
5041
+ * Make the automatic per-step commit when the policy calls for it.
5042
+ * Deterministic: driven by the step-completion signal (workflow_todo
5043
+ * tool_result / [DONE:ref] tags), never by model whimsy; the committed
5044
+ * ref set makes it exactly-once per step. Never throws — a commit hiccup
5045
+ * must not break the run.
5046
+ */
5047
+ async function autoCommitCompletedSteps(
5048
+ ctx: any,
5049
+ trigger: string,
5050
+ ): Promise<void> {
5051
+ try {
5052
+ const cwd = ctx?.cwd ?? process.cwd();
5053
+ if (!isGitRepoSync(cwd)) return;
5054
+ seedCommittedStepRefs();
5055
+ const completed = uncommittedDoneSteps();
5056
+ const dirty = await isDirty(pi as any, cwd);
5057
+ const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
5058
+ const decision = stepCommitDecision({
5059
+ mode,
5060
+ hasTodos: todoItems.length > 0,
5061
+ dirty,
5062
+ isInRepo: true,
5063
+ completed,
5064
+ });
5065
+ if (!decision.commit) return;
5066
+ const addRes = await piExecGit(["add", "-A"], cwd);
5067
+ if (addRes.exitCode !== 0) {
5068
+ try {
5069
+ ctx?.ui?.notify?.(
5070
+ `Per-step commit failed (git add): ${addRes.stderr.trim()}`,
5071
+ "error",
5072
+ );
5073
+ } catch (_e) {
5074
+ void _e;
5075
+ }
5076
+ return;
5077
+ }
5078
+ const commitRes = await piExecGit(["commit", "-m", decision.message], cwd);
5079
+ if (commitRes.exitCode !== 0) {
5080
+ try {
5081
+ ctx?.ui?.notify?.(
5082
+ `Per-step commit failed: ${commitRes.stderr.trim()}`,
5083
+ "error",
5084
+ );
5085
+ } catch (_e) {
5086
+ void _e;
5087
+ }
5088
+ return;
5089
+ }
5090
+ for (const ref of decision.refs) committedStepRefs.add(ref);
5091
+ try {
5092
+ ctx?.ui?.notify?.(
5093
+ `Committed ${decision.refs.join(", ")} — ${decision.message}`,
5094
+ "info",
5095
+ );
5096
+ } catch (_e) {
5097
+ void _e;
5098
+ }
5099
+ try {
5100
+ pi.appendEntry("workflow-step-commit", {
5101
+ refs: decision.refs,
5102
+ message: decision.message,
5103
+ trigger,
5104
+ at: Date.now(),
5105
+ });
5106
+ } catch (_e) {
5107
+ void _e;
5108
+ }
5109
+ } catch {
5110
+ // never break the agent run on a commit hiccup
5111
+ }
5112
+ }
5113
+
4738
5114
  pi.on("tool_result", async (_event, ctx) => {
4739
5115
  try {
4740
5116
  const leaf =
@@ -4746,8 +5122,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
4746
5122
  }
4747
5123
  });
4748
5124
 
5125
+ // Per-step commit trigger: the runner AWAITS tool_result handlers, so the
5126
+ // commit lands after the step is marked done and BEFORE the model starts
5127
+ // the next step — exactly one commit per step, at the moment of completion.
5128
+ pi.on("tool_result", async (event, ctx) => {
5129
+ try {
5130
+ const ev = event as any;
5131
+ if (ev?.toolName !== "workflow_todo") return;
5132
+ const action = String(ev?.input?.action ?? ev?.details?.action ?? "");
5133
+ if (!action || action === "list" || action === "clear") return;
5134
+ await autoCommitCompletedSteps(ctx, `workflow_todo:${action}`);
5135
+ } catch (_e) {
5136
+ void _e;
5137
+ }
5138
+ });
5139
+
4749
5140
  pi.on("turn_start", async (_event, ctx) => {
4750
5141
  footerInjectedThisTurn = false;
5142
+ // Seed the per-step commit ledger once per session: steps already done
5143
+ // (restored sessions, re-executed plans) never trigger a spurious commit.
5144
+ try {
5145
+ seedCommittedStepRefs();
5146
+ } catch (_e) {
5147
+ void _e;
5148
+ }
4751
5149
  if (!currentEntryId) return;
4752
5150
  const cwd = (ctx as any).cwd as string;
4753
5151
  try {
@@ -4778,6 +5176,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
4778
5176
  });
4779
5177
 
4780
5178
  pi.on("agent_settled", async () => {
5179
+ // Belt-and-braces: the run is over (completed, aborted or errored) — the
5180
+ // /commit bypass must not leak into whatever runs next.
5181
+ commitBypassActive = false;
4781
5182
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
4782
5183
  });
4783
5184
 
@@ -4956,10 +5357,24 @@ export default function workflowExtension(pi: ExtensionAPI) {
4956
5357
 
4957
5358
  // Track progress (all modes, whenever todos exist)
4958
5359
  pi.on("turn_end", async (event, ctx) => {
5360
+ // Disarm the /commit bypass as soon as any turn ends: the /commit flow's
5361
+ // `git commit` runs as a tool call WITHIN its turn, so this never disarms
5362
+ // too early — and nothing after the turn inherits the exemption.
5363
+ commitBypassActive = false;
4959
5364
  if (todoItems.length === 0) return;
4960
5365
  if (!isAssistantMessage(event.message as AgentMessage)) return;
4961
5366
  const text = getTextContent(event.message as AssistantMessage);
4962
5367
  const result = markCompletedRefs(text, todoItems);
5368
+ // Fallback per-step commit for steps completed via [DONE:ref] tags in the
5369
+ // response text (the workflow_todo path already committed at its
5370
+ // tool_result; the committed-ref set makes this a no-op then).
5371
+ if (result.marked > 0) {
5372
+ try {
5373
+ await autoCommitCompletedSteps(ctx, "turn_end:done-tags");
5374
+ } catch (_e) {
5375
+ void _e;
5376
+ }
5377
+ }
4963
5378
  if (result.unknown.length > 0 || result.ambiguous.length > 0) {
4964
5379
  lastRefMisses = { refs: [...result.unknown, ...result.ambiguous] };
4965
5380
  try {
@@ -5516,7 +5931,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5516
5931
  const list = formatTodoNumberedList(todoItems);
5517
5932
  const content = plannotatorActive
5518
5933
  ? `**Plan ready** (${todoItems.length} steps)\\n\\nTo review in browser, ensure SSH port forwarding is active:\\n\`\`\`\\nLocalForward 9999 localhost:9999\\n\`\`\`\\nThen set \`PLANNOTATOR_REMOTE=1\` and \`PLANNOTATOR_PORT=9999\`.\\n\\nOr type "Execute the plan" to build directly.\\n\\nRemaining:\\n${list}`
5519
- : `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine.`;
5934
+ : `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine.${commitDirectiveLine(ctx)}`;
5520
5935
  (pi as any).sendMessage?.(
5521
5936
  {
5522
5937
  customType: "workflow-handoff-fallback",
@@ -5932,7 +6347,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5932
6347
  display: true,
5933
6348
  };
5934
6349
  const firstTodo = todoItems[0];
5935
- const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
6350
+ const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
5936
6351
  (pi as any).sendMessage?.(planTodoListMessage, {
5937
6352
  deliverAs: "followUp",
5938
6353
  });
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
7
7
  "description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
8
  "license": "MIT",
9
9
  "scripts": {
10
- "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts review.test.ts review-ui.test.ts",
10
+ "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "index.ts",
27
27
  "utils.ts",
28
28
  "checkpoint.ts",
29
+ "commit-policy.ts",
29
30
  "roles.ts",
30
31
  "permissions.ts",
31
32
  "review.ts",
package/permissions.ts CHANGED
@@ -824,6 +824,104 @@ function classifyGit(s: string): CommandClass {
824
824
  return GIT_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
825
825
  }
826
826
 
827
+ // ── Commit-policy classification ─────────────────────────────────
828
+
829
+ /** Git subcommands the commit policy gates (`workflow.commitPolicy`). */
830
+ export type CommitPolicyHit = "add" | "commit" | "push";
831
+
832
+ const COMMIT_POLICY_SUBCOMMANDS: ReadonlySet<string> = new Set([
833
+ "add",
834
+ "commit",
835
+ "push",
836
+ ]);
837
+
838
+ /** Global `git` options that take a separate value (`git -C path status`). */
839
+ const GIT_GLOBAL_VALUE_OPTIONS: ReadonlySet<string> = new Set([
840
+ "-C",
841
+ "--git-dir",
842
+ "--work-tree",
843
+ "--namespace",
844
+ "--super-prefix",
845
+ "--exec-path",
846
+ ]);
847
+
848
+ /** Global `git` flags that take no value (`git --no-pager log`). */
849
+ const GIT_GLOBAL_FLAG_OPTIONS: ReadonlySet<string> = new Set([
850
+ "--no-pager",
851
+ "--paginate",
852
+ "--no-replace-objects",
853
+ "--bare",
854
+ "--no-optional-locks",
855
+ "--literal-pathspecs",
856
+ "--html-path",
857
+ "--man-path",
858
+ "--info-path",
859
+ "--version",
860
+ ]);
861
+
862
+ /**
863
+ * Extract the subcommand a `git …` invocation resolves to, skipping global
864
+ * options. `null` when no subcommand is found. Mirrors classifyGit's
865
+ * option-skipping, but deliberately parses *through* `-c key=value` pairs:
866
+ * a config pair cannot turn a read-only subcommand into a commit, and the
867
+ * policy classifier must see the real subcommand (`git -c x=y commit`).
868
+ */
869
+ function gitSubcommandAt(words: string[], gitIndex: number): string | null {
870
+ let i = gitIndex + 1;
871
+ while (i < words.length) {
872
+ const w = unquote(words[i]);
873
+ if (!w) {
874
+ i += 1;
875
+ continue;
876
+ }
877
+ if (w === "-c") {
878
+ i += 2; // -c key=value pair
879
+ continue;
880
+ }
881
+ if (w.startsWith("-c") && w.includes("=")) {
882
+ i += 1; // fused -ckey=value (defensive; git wants a space)
883
+ continue;
884
+ }
885
+ if (w.startsWith("--") && w.includes("=")) {
886
+ i += 1; // --git-dir=/x fused form
887
+ continue;
888
+ }
889
+ if (GIT_GLOBAL_VALUE_OPTIONS.has(w)) {
890
+ i += 2;
891
+ continue;
892
+ }
893
+ if (GIT_GLOBAL_FLAG_OPTIONS.has(w)) {
894
+ i += 1;
895
+ continue;
896
+ }
897
+ return w.toLowerCase();
898
+ }
899
+ return null;
900
+ }
901
+
902
+ /**
903
+ * Commit-policy classifier: does this command invoke `git add`, `git commit`
904
+ * or `git push` — in any segment of a compound command, including wrapper
905
+ * forms (`env git commit`, `nice -n 5 git add`) and global-option forms
906
+ * (`git -C path add`, `git.exe push`)? Returns the first matched subcommand,
907
+ * or null. Fail-closed by design: false positives (an unquoted
908
+ * `echo git commit`) are acceptable; false negatives are not.
909
+ */
910
+ export function gitCommitPolicyHit(command: string): CommitPolicyHit | null {
911
+ if (typeof command !== "string" || !command.trim()) return null;
912
+ for (const segment of splitCommandSegments(command)) {
913
+ const words = wordsOf(segment);
914
+ for (let g = 0; g < words.length; g++) {
915
+ if (normalizeBinary(words[g]) !== "git") continue;
916
+ const sub = gitSubcommandAt(words, g);
917
+ if (sub && COMMIT_POLICY_SUBCOMMANDS.has(sub)) {
918
+ return sub as CommitPolicyHit;
919
+ }
920
+ }
921
+ }
922
+ return null;
923
+ }
924
+
827
925
  function classifyDocker(s: string): CommandClass {
828
926
  const sub = wordAt(s, 1)?.toLowerCase();
829
927
  if (!sub) return "full";