@dieulc/workflow 0.5.0 → 0.5.1
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 +12 -2
- package/commit-policy.ts +170 -0
- package/index.ts +126 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
|
|
|
41
41
|
| `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
|
|
42
42
|
| `/review-status` | Show review phase, model, plan hash, findings and any error |
|
|
43
43
|
| `/review` | Run one review round against the current plan file now |
|
|
44
|
-
| `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated |
|
|
44
|
+
| `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated; the confirmation arms the commit exemption for that run |
|
|
45
45
|
| `/commit-policy [never\|ask\|allow] [global\|project]` | View or set the commit policy (default `never`: the agent cannot `git add`/`commit`/`push` — you commit, or use `/commit`) |
|
|
46
46
|
|
|
47
47
|
## Model Profiles
|
|
@@ -267,7 +267,7 @@ Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global
|
|
|
267
267
|
|
|
268
268
|
## Commit policy
|
|
269
269
|
|
|
270
|
-
By default the agent **cannot touch your git history**: the tool gate deterministically blocks `git add`, `git commit` and `git push` — including compound commands (`git add -A && git commit -m x`), global-option forms (`git -C path add`) and `powershell` wrappers. You commit manually, or run `/commit` for an AI-summarized, confirmation-gated commit
|
|
270
|
+
By default the agent **cannot touch your git history**: the tool gate deterministically blocks `git add`, `git commit` and `git push` — including compound commands (`git add -A && git commit -m x`), global-option forms (`git -C path add`) and `powershell` wrappers. You commit manually, or run `/commit` for an AI-summarized, confirmation-gated commit. Read-only git (`status`, `log`, `diff`, `branch -a`, `config --get`, …) stays free, and plan mode keeps its stricter read-only rules regardless.
|
|
271
271
|
|
|
272
272
|
The default exists because prompt-only rules are soft: models habitually commit per plan step, and "never commit unless asked" instructions in CLAUDE.md-style files are famously ignored (anthropics/claude-code#58883, #68724, #7029). Claude Code, Codex CLI and OpenCode all treat agent-initiated commits as something to gate or approve — so the policy here is enforced where commands execute, not in prose:
|
|
273
273
|
|
|
@@ -288,6 +288,16 @@ The default exists because prompt-only rules are soft: models habitually commit
|
|
|
288
288
|
|
|
289
289
|
The status line always shows the active policy (`▶ build 2/5 · commits:never`), so you can see at a glance what the agent may do to history.
|
|
290
290
|
|
|
291
|
+
### `/commit` under any policy
|
|
292
|
+
|
|
293
|
+
`/commit` works under `never` and `allow`-during-a-plan because it is the user's own request. The flow is multi-turn (read the staged diff → confirm the message in a questionnaire → commit), so the exemption is **armed at the user's confirmation**, not when the flow starts:
|
|
294
|
+
|
|
295
|
+
- `/commit` records a *pending flow* and injects its agent turn. Nothing is exempted yet.
|
|
296
|
+
- When the confirmation questionnaire is answered **positively** (✅ Commit, or a custom message), the extension arms a scoped *commit authorization*. Choosing ❌ Cancel clears the flow and arms nothing.
|
|
297
|
+
- The authorization exempts **only `git commit`** — `git add` and `git push` stay blocked — and is valid until the run settles (`agent_settled`), additionally bounded by a 10-minute TTL and a 10-commit cap. It is also dropped on `session_shutdown` and on rewind (`session_tree`).
|
|
298
|
+
|
|
299
|
+
The confirmation step is required: with no positive answer no authorization exists and the policy keeps blocking. `-a` staging happens extension-side before the flow, outside the tool gate.
|
|
300
|
+
|
|
291
301
|
```text
|
|
292
302
|
/commit-policy # status: mode, which scope supplied it, both settings paths
|
|
293
303
|
/commit-policy never # set globally (~/.pi/agent/settings.json)
|
package/commit-policy.ts
CHANGED
|
@@ -321,6 +321,176 @@ export function stepCommitDecision(
|
|
|
321
321
|
};
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
// ── /commit authorization (scoped exemption from the gate) ───────────
|
|
325
|
+
//
|
|
326
|
+
// The /commit flow is multi-turn (read diff → confirm via questionnaire →
|
|
327
|
+
// commit), so a single-turn exemption is always disarmed before the commit
|
|
328
|
+
// runs. Instead the flow starts a PendingCommitFlow and the authorization is
|
|
329
|
+
// armed only when the user CONFIRMS in the extension-owned questionnaire
|
|
330
|
+
// tool. The authorization is valid until the run settles, bounded by a TTL and
|
|
331
|
+
// a commit cap, and exempts ONLY `git commit` (never `add`/`push`).
|
|
332
|
+
|
|
333
|
+
/** How long a /commit flow may sit awaiting the user's decision. */
|
|
334
|
+
export const COMMIT_FLOW_TTL_MS = 30 * 60 * 1000;
|
|
335
|
+
|
|
336
|
+
/** How long an armed authorization stays valid (run-settle is the primary bound). */
|
|
337
|
+
export const COMMIT_AUTH_TTL_MS = 10 * 60 * 1000;
|
|
338
|
+
|
|
339
|
+
/** Defense-in-depth cap on commits made under one /commit authorization. */
|
|
340
|
+
export const COMMIT_AUTH_MAX_COMMITS = 10;
|
|
341
|
+
|
|
342
|
+
let commitFlowCounter = 0;
|
|
343
|
+
|
|
344
|
+
/** A /commit flow awaiting the user's confirmation. */
|
|
345
|
+
export interface PendingCommitFlow {
|
|
346
|
+
id: string;
|
|
347
|
+
startedAt: number;
|
|
348
|
+
expiresAt: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** An armed exemption from the commit gate, created at the user's confirmation. */
|
|
352
|
+
export interface CommitAuthorizationState {
|
|
353
|
+
flowId: string;
|
|
354
|
+
armedAt: number;
|
|
355
|
+
expiresAt: number;
|
|
356
|
+
commits: number;
|
|
357
|
+
maxCommits: number;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Start a new /commit flow (id is unique within the process). */
|
|
361
|
+
export function startCommitFlow(
|
|
362
|
+
now: number,
|
|
363
|
+
ttlMs: number = COMMIT_FLOW_TTL_MS,
|
|
364
|
+
): PendingCommitFlow {
|
|
365
|
+
commitFlowCounter += 1;
|
|
366
|
+
return {
|
|
367
|
+
id: `commit-${now}-${commitFlowCounter}`,
|
|
368
|
+
startedAt: now,
|
|
369
|
+
expiresAt: now + ttlMs,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Arm the exemption for one flow. Never throws. */
|
|
374
|
+
export function armCommitAuthorization(input: {
|
|
375
|
+
flowId: string;
|
|
376
|
+
now: number;
|
|
377
|
+
ttlMs?: number;
|
|
378
|
+
maxCommits?: number;
|
|
379
|
+
}): CommitAuthorizationState {
|
|
380
|
+
const ttlMs = input.ttlMs ?? COMMIT_AUTH_TTL_MS;
|
|
381
|
+
return {
|
|
382
|
+
flowId: input.flowId,
|
|
383
|
+
armedAt: input.now,
|
|
384
|
+
expiresAt: input.now + ttlMs,
|
|
385
|
+
commits: 0,
|
|
386
|
+
maxCommits: input.maxCommits ?? COMMIT_AUTH_MAX_COMMITS,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** True only while an authorization is present, unexpired and under its cap. */
|
|
391
|
+
export function isAuthorizationActive(
|
|
392
|
+
auth: CommitAuthorizationState | null | undefined,
|
|
393
|
+
now: number,
|
|
394
|
+
): boolean {
|
|
395
|
+
if (!auth) return false;
|
|
396
|
+
if (!(now < auth.expiresAt)) return false;
|
|
397
|
+
return auth.commits < auth.maxCommits;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A normalized questionnaire question (only the fields the detector needs). */
|
|
401
|
+
export interface CommitConfirmationQuestion {
|
|
402
|
+
question?: string;
|
|
403
|
+
header?: string;
|
|
404
|
+
options?: { label?: string; description?: string }[];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Is this questionnaire the /commit confirmation? Signals, in order: the
|
|
409
|
+
* "Commit" header, the canonical question text, or the "✅ Commit" option
|
|
410
|
+
* label. Deliberately tolerant — a missed match leaves the policy blocking
|
|
411
|
+
* (fail safe), so false negatives are a UX regression, false positives a risk.
|
|
412
|
+
*/
|
|
413
|
+
export function isCommitConfirmation(
|
|
414
|
+
questions: readonly CommitConfirmationQuestion[] | undefined,
|
|
415
|
+
): boolean {
|
|
416
|
+
if (!Array.isArray(questions)) return false;
|
|
417
|
+
return questions.some((q) => {
|
|
418
|
+
if (!q || typeof q !== "object") return false;
|
|
419
|
+
if (typeof q.header === "string" && /^commit$/i.test(q.header.trim())) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
typeof q.question === "string" &&
|
|
424
|
+
/commit these staged changes/i.test(q.question)
|
|
425
|
+
) {
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
return (q.options ?? []).some(
|
|
429
|
+
(o: { label?: string; description?: string }) =>
|
|
430
|
+
typeof o?.label === "string" && /✅\s*commit/i.test(o.label),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** One questionnaire answer, as far as the decision needs to know. */
|
|
436
|
+
export interface CommitConfirmationAnswer {
|
|
437
|
+
answer?: string;
|
|
438
|
+
wasCustom?: boolean;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Did the user authorize the commit? Cancel (`cancelled` or a ❌/cancel-only
|
|
443
|
+
* label) never arms. Freeform text always arms (it is the user's own commit
|
|
444
|
+
* message) unless it is exactly a cancel token. A canned label arms unless it
|
|
445
|
+
* reads as cancel without commit.
|
|
446
|
+
*/
|
|
447
|
+
export function isPositiveCommitDecision(input: {
|
|
448
|
+
cancelled?: boolean;
|
|
449
|
+
answer?: CommitConfirmationAnswer;
|
|
450
|
+
}): boolean {
|
|
451
|
+
if (input?.cancelled) return false;
|
|
452
|
+
const answer = input?.answer;
|
|
453
|
+
if (!answer) return false;
|
|
454
|
+
const label = String(answer.answer ?? "").trim();
|
|
455
|
+
if (!label) return false;
|
|
456
|
+
const isBareCancel = /^(❌\s*)?cancel[.!]?$/i.test(label);
|
|
457
|
+
if (isBareCancel) return false;
|
|
458
|
+
if (answer.wasCustom) return true;
|
|
459
|
+
if (/\bcancel\b/i.test(label) && !/\bcommit\b/i.test(label)) return false;
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The commit-policy gate outcome for one tool call. */
|
|
464
|
+
export type CommitGateDecision = "allow" | "block" | "ask";
|
|
465
|
+
|
|
466
|
+
/** Git subcommands the commit policy gates (mirrors permissions.ts CommitPolicyHit). */
|
|
467
|
+
export type CommitGateHit = "add" | "commit" | "push";
|
|
468
|
+
|
|
469
|
+
export interface CommitGateInput {
|
|
470
|
+
policy: CommitPolicyMode;
|
|
471
|
+
hit: CommitGateHit;
|
|
472
|
+
hasTodos: boolean;
|
|
473
|
+
auth?: CommitAuthorizationState | null;
|
|
474
|
+
now: number;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The pure commit-policy decision. Ordering mirrors the original inline gate:
|
|
479
|
+
* an `allow` policy with no active plan owns nothing → allow; `ask` keeps
|
|
480
|
+
* fresh staging free; an armed authorization exempts ONLY `git commit`; then
|
|
481
|
+
* never/allow block and anything left (`ask`) prompts. Never throws.
|
|
482
|
+
*/
|
|
483
|
+
export function decideCommitGate(input: CommitGateInput): CommitGateDecision {
|
|
484
|
+
const { policy, hit, hasTodos } = input;
|
|
485
|
+
if (policy === "allow" && !hasTodos) return "allow";
|
|
486
|
+
if (policy === "ask" && hit === "add") return "allow";
|
|
487
|
+
if (hit === "commit" && isAuthorizationActive(input.auth, input.now)) {
|
|
488
|
+
return "allow";
|
|
489
|
+
}
|
|
490
|
+
if (policy === "never" || policy === "allow") return "block";
|
|
491
|
+
return "ask";
|
|
492
|
+
}
|
|
493
|
+
|
|
324
494
|
/**
|
|
325
495
|
* Read-modify-write `workflow.commitPolicy` into a settings file, preserving
|
|
326
496
|
* every other key in that file (Pi owns settings.json — we must not clobber it).
|
package/index.ts
CHANGED
|
@@ -89,12 +89,24 @@ import {
|
|
|
89
89
|
type WorkflowRole,
|
|
90
90
|
} from "./permissions.ts";
|
|
91
91
|
import {
|
|
92
|
+
COMMIT_AUTH_MAX_COMMITS,
|
|
93
|
+
COMMIT_AUTH_TTL_MS,
|
|
94
|
+
COMMIT_FLOW_TTL_MS,
|
|
95
|
+
armCommitAuthorization,
|
|
92
96
|
commitConfigPaths,
|
|
97
|
+
decideCommitGate,
|
|
93
98
|
describeCommitPolicyMode,
|
|
99
|
+
isAuthorizationActive,
|
|
100
|
+
isCommitConfirmation,
|
|
101
|
+
isPositiveCommitDecision,
|
|
94
102
|
readCommitPolicyConfig,
|
|
103
|
+
startCommitFlow,
|
|
95
104
|
stepCommitDecision,
|
|
96
105
|
writeCommitPolicyConfig,
|
|
106
|
+
type CommitAuthorizationState,
|
|
107
|
+
type CommitGateHit,
|
|
97
108
|
type CommitPolicyMode,
|
|
109
|
+
type PendingCommitFlow,
|
|
98
110
|
type StepCommitCandidate,
|
|
99
111
|
} from "./commit-policy.ts";
|
|
100
112
|
import {
|
|
@@ -400,11 +412,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
400
412
|
let todoItems: TodoItem[] = [];
|
|
401
413
|
// Plan file the current todo list was derived from (drives merge vs replace).
|
|
402
414
|
let todoPlanPath: string | undefined;
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
415
|
+
// ── /commit flow authorization ──
|
|
416
|
+
// The /commit flow is multi-turn (read diff → confirm → commit), so a
|
|
417
|
+
// single-turn exemption is always disarmed before the commit runs. Instead:
|
|
418
|
+
// pendingCommitFlow — set when /commit injects its agent turn, cleared on
|
|
419
|
+
// the user's confirmation (or a negative/cancel answer),
|
|
420
|
+
// so only THIS flow's questionnaire can arm.
|
|
421
|
+
// commitAuthorization — armed only when the user confirms in that
|
|
422
|
+
// questionnaire; exempts ONLY `git commit` (never
|
|
423
|
+
// add/push) until the run settles, bounded by a TTL
|
|
424
|
+
// and a commit cap.
|
|
425
|
+
// Both are cleared on agent_settled, session_shutdown and session_tree.
|
|
426
|
+
let pendingCommitFlow: PendingCommitFlow | null = null;
|
|
427
|
+
let commitAuthorization: CommitAuthorizationState | null = null;
|
|
408
428
|
// ── Deterministic per-step commits (allow mode) ──
|
|
409
429
|
// In 'allow' mode the EXTENSION — not the model — owns commit timing:
|
|
410
430
|
// exactly one commit per batch of newly completed plan steps, made at the
|
|
@@ -2532,16 +2552,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2532
2552
|
|
|
2533
2553
|
// Inject a user message → triggers an agent turn that proposes + confirms + commits
|
|
2534
2554
|
const message = buildCommitPrompt(statSummary, stageAll);
|
|
2535
|
-
//
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
(pi as any).
|
|
2543
|
-
|
|
2544
|
-
|
|
2555
|
+
// Start a commit flow. The exemption is NOT armed here: it is armed only
|
|
2556
|
+
// when the user confirms in THIS flow's questionnaire (see the
|
|
2557
|
+
// questionnaire tool), so an unconfirmed commit stays blocked under any
|
|
2558
|
+
// policy. A flow TTL bounds an abandoned confirmation.
|
|
2559
|
+
pendingCommitFlow = startCommitFlow(Date.now(), COMMIT_FLOW_TTL_MS);
|
|
2560
|
+
commitAuthorization = null;
|
|
2561
|
+
try {
|
|
2562
|
+
if ((pi as any).sendUserMessage) {
|
|
2563
|
+
await (pi as any).sendUserMessage(message);
|
|
2564
|
+
} else {
|
|
2565
|
+
await (pi as any).sendMessage?.(
|
|
2566
|
+
{ customType: "workflow-commit", content: message, display: true },
|
|
2567
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
} catch (e: any) {
|
|
2571
|
+
// The turn never started → no questionnaire can arm; do not leave a
|
|
2572
|
+
// stale flow behind.
|
|
2573
|
+
pendingCommitFlow = null;
|
|
2574
|
+
ctx.ui.notify(
|
|
2575
|
+
`Failed to start the commit flow: ${String(e?.message ?? e ?? e)}`,
|
|
2576
|
+
"error",
|
|
2545
2577
|
);
|
|
2546
2578
|
}
|
|
2547
2579
|
}
|
|
@@ -5801,6 +5833,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5801
5833
|
overlayActive = false;
|
|
5802
5834
|
}
|
|
5803
5835
|
|
|
5836
|
+
// /commit authorization: arm the commit-policy exemption ONLY when the
|
|
5837
|
+
// user confirms THIS flow's commit questionnaire. A cancel (or any
|
|
5838
|
+
// non-confirmation questionnaire) clears the pending flow and arms
|
|
5839
|
+
// nothing, so the policy keeps blocking. Headless never reaches here
|
|
5840
|
+
// (early return above), which is the intended fail-closed behaviour.
|
|
5841
|
+
if (pendingCommitFlow && isCommitConfirmation(questions)) {
|
|
5842
|
+
const cancelled = !result || result.cancelled === true;
|
|
5843
|
+
const answer =
|
|
5844
|
+
!result || result.cancelled ? undefined : result.answers?.[0];
|
|
5845
|
+
if (isPositiveCommitDecision({ cancelled, answer })) {
|
|
5846
|
+
commitAuthorization = armCommitAuthorization({
|
|
5847
|
+
flowId: pendingCommitFlow.id,
|
|
5848
|
+
now: Date.now(),
|
|
5849
|
+
ttlMs: COMMIT_AUTH_TTL_MS,
|
|
5850
|
+
maxCommits: COMMIT_AUTH_MAX_COMMITS,
|
|
5851
|
+
});
|
|
5852
|
+
try {
|
|
5853
|
+
ctx.ui.notify?.(
|
|
5854
|
+
"Commit authorized — this run may commit the staged changes.",
|
|
5855
|
+
"info",
|
|
5856
|
+
);
|
|
5857
|
+
} catch (_e) {
|
|
5858
|
+
void _e;
|
|
5859
|
+
}
|
|
5860
|
+
}
|
|
5861
|
+
pendingCommitFlow = null;
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5804
5864
|
if (!result || result.cancelled) {
|
|
5805
5865
|
return {
|
|
5806
5866
|
content: [{ type: "text", text: "User cancelled questionnaire" }],
|
|
@@ -6421,6 +6481,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6421
6481
|
}
|
|
6422
6482
|
ctx.ui.setStatus("workflow", undefined);
|
|
6423
6483
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
6484
|
+
// Drop any /commit authorization or flow: a reload/quit must never leave
|
|
6485
|
+
// an exemption armed for whatever runs next.
|
|
6486
|
+
commitAuthorization = null;
|
|
6487
|
+
pendingCommitFlow = null;
|
|
6424
6488
|
// Release the reviewer session (and its custom tools) on shutdown so a
|
|
6425
6489
|
// reload or quit never leaves an orphaned in-process session behind.
|
|
6426
6490
|
await teardownReview();
|
|
@@ -6432,6 +6496,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6432
6496
|
todoItems = [];
|
|
6433
6497
|
todoPlanPath = undefined;
|
|
6434
6498
|
btwNotes = [];
|
|
6499
|
+
// A rewind must not preserve a /commit exemption across branches.
|
|
6500
|
+
commitAuthorization = null;
|
|
6501
|
+
pendingCommitFlow = null;
|
|
6435
6502
|
try {
|
|
6436
6503
|
const branch = ctx.sessionManager.getBranch();
|
|
6437
6504
|
for (const e of branch as any[]) {
|
|
@@ -6468,32 +6535,52 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6468
6535
|
* allow → automatic per-step commits while a plan todo list is active
|
|
6469
6536
|
* (the extension owns commit timing — model-initiated git add/commit/push
|
|
6470
6537
|
* are blocked); outside a plan the model may commit as before. The
|
|
6471
|
-
* user-invoked /commit flow arms `
|
|
6472
|
-
*
|
|
6473
|
-
*
|
|
6538
|
+
* user-invoked /commit flow arms `commitAuthorization` ONLY when the user
|
|
6539
|
+
* confirms its questionnaire; that exemption covers just `git commit` for
|
|
6540
|
+
* the rest of the run (staging is extension-side; `push` and `add` are
|
|
6541
|
+
* never exempted).
|
|
6542
|
+
*
|
|
6543
|
+
* The decision itself is the pure `decideCommitGate`; this wrapper supplies
|
|
6544
|
+
* the runtime inputs and renders the block reasons / ask overlay.
|
|
6474
6545
|
*/
|
|
6475
6546
|
async function commitPolicyGate(
|
|
6476
6547
|
event: any,
|
|
6477
6548
|
ctx: any,
|
|
6478
6549
|
cwd: string,
|
|
6479
6550
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
6480
|
-
const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
|
|
6481
|
-
// allow outside a plan: no per-step automation to own commit timing, so
|
|
6482
|
-
// the model keeps its previous freedom.
|
|
6483
|
-
if (policy === "allow" && todoItems.length === 0) return undefined;
|
|
6484
6551
|
const cmd = String(event?.input?.command ?? "");
|
|
6485
6552
|
const hit = gitCommitPolicyHit(cmd);
|
|
6486
6553
|
if (!hit) return undefined;
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6554
|
+
const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
|
|
6555
|
+
const now = Date.now();
|
|
6556
|
+
const decision = decideCommitGate({
|
|
6557
|
+
policy,
|
|
6558
|
+
hit: hit as CommitGateHit,
|
|
6559
|
+
hasTodos: todoItems.length > 0,
|
|
6560
|
+
auth: commitAuthorization,
|
|
6561
|
+
now,
|
|
6562
|
+
});
|
|
6563
|
+
if (decision === "allow") {
|
|
6564
|
+
// Count a granted `git commit` against the authorization, but only when
|
|
6565
|
+
// the authorization was actually the deciding factor (never/ask, or
|
|
6566
|
+
// allow-with-an-active-plan). `allow` outside a plan is free regardless.
|
|
6567
|
+
const authWasDecisive =
|
|
6568
|
+
hit === "commit" &&
|
|
6569
|
+
commitAuthorization !== null &&
|
|
6570
|
+
!(policy === "allow" && todoItems.length === 0);
|
|
6571
|
+
if (authWasDecisive && commitAuthorization) {
|
|
6572
|
+
const auth = commitAuthorization;
|
|
6573
|
+
if (!isAuthorizationActive(auth, now)) {
|
|
6574
|
+
commitAuthorization = null;
|
|
6575
|
+
} else {
|
|
6576
|
+
const next = { ...auth, commits: auth.commits + 1 };
|
|
6577
|
+
commitAuthorization = next.commits >= next.maxCommits ? null : next;
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6493
6580
|
return undefined;
|
|
6494
6581
|
}
|
|
6495
6582
|
|
|
6496
|
-
if (
|
|
6583
|
+
if (decision === "block") {
|
|
6497
6584
|
return {
|
|
6498
6585
|
block: true,
|
|
6499
6586
|
reason:
|
|
@@ -6507,7 +6594,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6507
6594
|
cmd,
|
|
6508
6595
|
};
|
|
6509
6596
|
}
|
|
6510
|
-
//
|
|
6597
|
+
// decision === "ask" → confirmation overlay. Headless has no one to ask →
|
|
6511
6598
|
// fail closed rather than commit unbidden.
|
|
6512
6599
|
const ui = ctx?.ui;
|
|
6513
6600
|
if (!ui || !ctx.hasUI) {
|
|
@@ -6886,9 +6973,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6886
6973
|
});
|
|
6887
6974
|
|
|
6888
6975
|
pi.on("agent_settled", async () => {
|
|
6889
|
-
//
|
|
6890
|
-
//
|
|
6891
|
-
|
|
6976
|
+
// The run is over (completed, aborted or errored). The /commit flow lives
|
|
6977
|
+
// within one run, so both the armed exemption and any unconfirmed flow are
|
|
6978
|
+
// dropped here — nothing that runs next inherits them.
|
|
6979
|
+
commitAuthorization = null;
|
|
6980
|
+
pendingCommitFlow = null;
|
|
6892
6981
|
// keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
|
|
6893
6982
|
});
|
|
6894
6983
|
|
|
@@ -7067,10 +7156,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7067
7156
|
|
|
7068
7157
|
// Track progress (all modes, whenever todos exist)
|
|
7069
7158
|
pi.on("turn_end", async (event, ctx) => {
|
|
7070
|
-
//
|
|
7071
|
-
//
|
|
7072
|
-
//
|
|
7073
|
-
|
|
7159
|
+
// NOTE: do NOT disarm the /commit authorization here. `turn_end` fires
|
|
7160
|
+
// after EVERY assistant response, but /commit is multi-turn (read diff →
|
|
7161
|
+
// confirm → commit), so disarming per turn kills the exemption before the
|
|
7162
|
+
// commit turn. Lifetime is the run: `agent_settled` clears it.
|
|
7074
7163
|
if (todoItems.length === 0) return;
|
|
7075
7164
|
if (!isAssistantMessage(event.message as AgentMessage)) return;
|
|
7076
7165
|
const text = getTextContent(event.message as AssistantMessage);
|