@dieulc/workflow 0.3.1 → 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/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,
@@ -132,6 +142,7 @@ import {
132
142
  type ReviewTranscriptOp,
133
143
  } from "./review.ts";
134
144
  import { createReviewRuntime, type ReviewRuntime } from "./review-runtime.ts";
145
+ import { findParentProviderSurface } from "./review-providers.ts";
135
146
  import {
136
147
  createReviewBandGate,
137
148
  createReviewLineBuffer,
@@ -371,6 +382,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
371
382
  let todoItems: TodoItem[] = [];
372
383
  // Plan file the current todo list was derived from (drives merge vs replace).
373
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;
374
400
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
375
401
  let turnsSinceLastTodoWrite = 0;
376
402
  let turnsSinceLastReminder = 0;
@@ -599,14 +625,27 @@ export default function workflowExtension(pi: ExtensionAPI) {
599
625
  function updateStatus(ctx: ExtensionContext) {
600
626
  const roleLabel = activeRoleLabel();
601
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}`;
602
641
  const staleMarker = todoStale ? " ⚠" : "";
603
642
  const done = todoItems.filter((t) => isDone(t)).length;
604
643
  const total = todoItems.length;
605
644
  if (workflowMode === "build") {
606
645
  const label =
607
646
  total > 0
608
- ? `▶ build ${done}/${total}${staleMarker}${suffix}`
609
- : `▶ build${suffix}`;
647
+ ? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
648
+ : `▶ build${suffix}${policy}`;
610
649
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
611
650
  } else if (workflowMode === "plan") {
612
651
  const base = plannotatorActive
@@ -614,16 +653,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
614
653
  : "⏸ plan";
615
654
  const label =
616
655
  total > 0
617
- ? `${base} ${done}/${total}${staleMarker}${suffix}`
618
- : `${base}${suffix}`;
656
+ ? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
657
+ : `${base}${suffix}${policy}`;
619
658
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
620
659
  } else if (total > 0) {
621
660
  ctx.ui.setStatus(
622
661
  "workflow",
623
- ctx.ui.theme.fg("muted", `◦ ${done}/${total}${staleMarker}${suffix}`),
662
+ ctx.ui.theme.fg(
663
+ "muted",
664
+ `◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
665
+ ),
624
666
  );
625
667
  } else {
626
- ctx.ui.setStatus("workflow", undefined);
668
+ ctx.ui.setStatus(
669
+ "workflow",
670
+ ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
671
+ );
627
672
  }
628
673
  if (todoItems.length > 0) {
629
674
  ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
@@ -891,6 +936,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
891
936
  },
892
937
  findModel: (provider: string, id: string) =>
893
938
  (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
939
+ // Structural probe of the live parent context, evaluated at reviewer
940
+ // session-creation time. Providers the parent registered (e.g. via an
941
+ // extension) that the isolated reviewer runtime does not compose yet
942
+ // are seeded onto it before the session exists; when the probe finds
943
+ // no surface, nothing is seeded and the preflight reports the escape
944
+ // hatch. No-op for providers every runtime composes by construction.
945
+ parentProviders: () => findParentProviderSurface(currentSessionCtx),
894
946
  planPath: () => currentPlanFile,
895
947
  readPlan: () => {
896
948
  const p = currentPlanFile;
@@ -1192,6 +1244,112 @@ export default function workflowExtension(pi: ExtensionAPI) {
1192
1244
  },
1193
1245
  });
1194
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
+
1195
1353
  pi.registerCommand("review-prompt", {
1196
1354
  description:
1197
1355
  "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
@@ -1837,7 +1995,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1837
1995
  };
1838
1996
  const firstTodo = todoItems[0];
1839
1997
  const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
1840
- 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).`;
1841
1999
  (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
1842
2000
  (pi as any).sendMessage?.(
1843
2001
  {
@@ -2113,6 +2271,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
2113
2271
 
2114
2272
  // Inject a user message → triggers an agent turn that proposes + confirms + commits
2115
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;
2116
2278
  if ((pi as any).sendUserMessage) {
2117
2279
  (pi as any).sendUserMessage(message);
2118
2280
  } else {
@@ -4586,6 +4748,118 @@ export default function workflowExtension(pi: ExtensionAPI) {
4586
4748
  updateStatus(ctx as any);
4587
4749
  });
4588
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
+
4589
4863
  // Block edits/writes + bash gating
4590
4864
  pi.on("tool_call", async (event, ctx) => {
4591
4865
  const cwd = (ctx as ExtensionContext).cwd;
@@ -4644,6 +4918,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
4644
4918
  } as any;
4645
4919
  }
4646
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
+ }
4647
4933
  if (
4648
4934
  (event.toolName === "edit" || event.toolName === "write") &&
4649
4935
  workflowMode === "plan"
@@ -4727,6 +5013,104 @@ export default function workflowExtension(pi: ExtensionAPI) {
4727
5013
  });
4728
5014
 
4729
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
+
4730
5114
  pi.on("tool_result", async (_event, ctx) => {
4731
5115
  try {
4732
5116
  const leaf =
@@ -4738,8 +5122,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
4738
5122
  }
4739
5123
  });
4740
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
+
4741
5140
  pi.on("turn_start", async (_event, ctx) => {
4742
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
+ }
4743
5149
  if (!currentEntryId) return;
4744
5150
  const cwd = (ctx as any).cwd as string;
4745
5151
  try {
@@ -4770,6 +5176,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
4770
5176
  });
4771
5177
 
4772
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;
4773
5182
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
4774
5183
  });
4775
5184
 
@@ -4948,10 +5357,24 @@ export default function workflowExtension(pi: ExtensionAPI) {
4948
5357
 
4949
5358
  // Track progress (all modes, whenever todos exist)
4950
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;
4951
5364
  if (todoItems.length === 0) return;
4952
5365
  if (!isAssistantMessage(event.message as AgentMessage)) return;
4953
5366
  const text = getTextContent(event.message as AssistantMessage);
4954
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
+ }
4955
5378
  if (result.unknown.length > 0 || result.ambiguous.length > 0) {
4956
5379
  lastRefMisses = { refs: [...result.unknown, ...result.ambiguous] };
4957
5380
  try {
@@ -5508,7 +5931,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5508
5931
  const list = formatTodoNumberedList(todoItems);
5509
5932
  const content = plannotatorActive
5510
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}`
5511
- : `**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)}`;
5512
5935
  (pi as any).sendMessage?.(
5513
5936
  {
5514
5937
  customType: "workflow-handoff-fallback",
@@ -5924,7 +6347,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5924
6347
  display: true,
5925
6348
  };
5926
6349
  const firstTodo = todoItems[0];
5927
- 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).`;
5928
6351
  (pi as any).sendMessage?.(planTodoListMessage, {
5929
6352
  deliverAs: "followUp",
5930
6353
  });
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.3.1",
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,9 +26,11 @@
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",
33
+ "review-providers.ts",
32
34
  "review-tools.ts",
33
35
  "review-runtime.ts",
34
36
  "review-ui.ts",