@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/README.md +118 -1
- package/commit-policy.ts +358 -0
- package/index.ts +432 -9
- package/package.json +4 -2
- package/permissions.ts +98 -0
- package/review-providers.ts +406 -0
- package/review-runtime.ts +108 -5
- package/review-ui.ts +1 -0
- package/review.ts +219 -4
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(
|
|
662
|
+
ctx.ui.theme.fg(
|
|
663
|
+
"muted",
|
|
664
|
+
`◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
|
|
665
|
+
),
|
|
624
666
|
);
|
|
625
667
|
} else {
|
|
626
|
-
ctx.ui.setStatus(
|
|
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
|
+
"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",
|