@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 +50 -1
- package/commit-policy.ts +358 -0
- package/index.ts +424 -9
- package/package.json +3 -2
- package/permissions.ts +98 -0
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
|
|
package/commit-policy.ts
ADDED
|
@@ -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(
|
|
662
|
+
ctx.ui.theme.fg(
|
|
663
|
+
"muted",
|
|
664
|
+
`◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
|
|
665
|
+
),
|
|
625
666
|
);
|
|
626
667
|
} else {
|
|
627
|
-
ctx.ui.setStatus(
|
|
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
|
+
"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";
|