@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/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
|
|
|
@@ -190,10 +192,81 @@ The reviewer is an in-process child session created with `createAgentSession`, a
|
|
|
190
192
|
|
|
191
193
|
An environment marker (`PI_WORKFLOW_REVIEW_CHILD`) is set only for the duration of session creation and restored immediately — the child runs in the same process, so leaking it would affect a later `/reload`.
|
|
192
194
|
|
|
195
|
+
### Provider seeding
|
|
196
|
+
|
|
197
|
+
The isolation above has one consequence: a provider that exists **only** because an extension registered it in the parent session (`pi.registerProvider(...)`) does not exist in the reviewer's freshly created runtime — every review round would fail with `No API key found for <provider>.` even though `/login` had stored a valid credential.
|
|
198
|
+
|
|
199
|
+
Pi composes providers from three sources: the **built-in catalog**, the **static `~/.pi/agent/models.json`**, and **extension registrations** on the parent runtime. Only the last one is parent-only. Review Mode bridges it with a **present-probe**, not layer names (`review-providers.ts`):
|
|
200
|
+
|
|
201
|
+
1. **Probe the parent** for a provider-registration surface: the session's model registry (`ctx.modelRegistry`) first, then the underlying runtime (`ctx.modelRuntime`). Both expose the same three accessors; a future Pi surface is picked up by adding one entry to the probe list.
|
|
202
|
+
2. **Compare with the child.** Providers the child already composes are skipped. The built-in catalog and `~/.pi/agent/models.json` are loaded by *every* runtime by construction, so for them the seeder is a guaranteed no-op — and any future globally-composed provider source is skipped automatically, with zero layer-name drift.
|
|
203
|
+
3. **Seed the difference, native-first.** For every provider the parent knows and the child does not, the seeder registers the parent's provider *object* when there is one, otherwise its config — each in its own try/catch, so a broken unrelated provider can never block a review. Idempotent: already-seeded ids are skipped on a second pass.
|
|
204
|
+
4. **Preflight auth, provider-agnostically.** After seeding and before the session is created, the reviewer runtime is asked `hasConfiguredAuth(provider) || checkAuth(provider)` for the *reviewer model's own* provider. On absence the review fails fast with the provider name, the seed report, and the universal escape hatch below — never the bare mid-review error.
|
|
205
|
+
|
|
206
|
+
Every bridged method is feature-detected: on a Pi version without a given accessor the seeder degrades to "nothing to seed" and the preflight reports the escape hatch instead of crashing.
|
|
207
|
+
|
|
208
|
+
**The universal escape hatch (works for any provider, even without this fix):** `~/.pi/agent/models.json` is loaded by every runtime, including the reviewer's:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"providers": {
|
|
213
|
+
"<provider>": {
|
|
214
|
+
"baseUrl": "https://…/v1",
|
|
215
|
+
"apiKey": "$MY_API_KEY",
|
|
216
|
+
"api": "openai-completions",
|
|
217
|
+
"models": [
|
|
218
|
+
{
|
|
219
|
+
"id": "<model-id>",
|
|
220
|
+
"name": "<display name>",
|
|
221
|
+
"reasoning": true,
|
|
222
|
+
"input": ["text"],
|
|
223
|
+
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
|
224
|
+
"contextWindow": 200000,
|
|
225
|
+
"maxTokens": 65536
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A stored credential (`/login <provider>` → `auth.json`) always wins over the `apiKey` template, so `$MY_API_KEY` needs no environment variable while you are logged in. Extensions that register the same provider at runtime keep working: their live model list replaces the static one in the parent, so nothing regresses there.
|
|
234
|
+
|
|
193
235
|
### Configuration
|
|
194
236
|
|
|
195
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.
|
|
196
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
|
+
|
|
197
270
|
## Settings
|
|
198
271
|
|
|
199
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`.
|
|
@@ -220,9 +293,25 @@ Review Mode settings live under `workflow.reviewMode`:
|
|
|
220
293
|
|
|
221
294
|
`"reviewMode": true` is accepted as shorthand for `{ "enabled": true }`.
|
|
222
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
|
+
|
|
223
312
|
## How it works
|
|
224
313
|
|
|
225
|
-
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-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.
|
|
226
315
|
|
|
227
316
|
### Todo protocol (industry-aligned)
|
|
228
317
|
|
|
@@ -232,6 +321,34 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gat
|
|
|
232
321
|
- **Reminder cadence** (Claude Code-style throttle): a hidden `[TODO LIST]` context block is injected only when `turnsSinceLastTodoWrite ≥ TURNS_SINCE_WRITE` **and** `turnsSinceLastReminder ≥ TURNS_BETWEEN_REMINDERS` (`=3`). Unresolved `[DONE:…]` refs from the previous turn bypass the throttle once so the model learns the right numbers. A one-line `[TODO …]` footer is appended to the first successful `edit`/`write`/`bash` result of each turn.
|
|
233
322
|
- **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (or approve a new plan) to reset.
|
|
234
323
|
|
|
324
|
+
## Troubleshooting
|
|
325
|
+
|
|
326
|
+
### `No API key found for <provider>.` in a Review Mode round
|
|
327
|
+
|
|
328
|
+
The transcript shows `Review Mode: … (could not prompt the reviewer: No API key found for <provider>.)`. This means the **reviewer session** could not compose auth for the provider — your login itself is fine (Plan/Build keep working off the same `auth.json`).
|
|
329
|
+
|
|
330
|
+
The reviewer runs in an isolated session without ambient extensions by design (see [Provider seeding](#provider-seeding)), so a provider registered only by an extension in the parent session is invisible to it unless seeded, and its auth must resolve on its own. Since 0.3.2 the provider is seeded automatically and a missing auth fails fast with a message naming the provider, what the seed found, and the escape hatch.
|
|
331
|
+
|
|
332
|
+
Fixes, in order of preference:
|
|
333
|
+
|
|
334
|
+
1. **`/login <provider>`** — a stored credential in `~/.pi/agent/auth.json` is read by every runtime, including the reviewer's. No environment variable needed.
|
|
335
|
+
2. **Static provider in `~/.pi/agent/models.json`** — the universal workaround (works even on an installed version without the seeding fix): add the provider under `"providers"` with `baseUrl`, `apiKey: "$MY_API_KEY"`, `api` and its `models` (template in [Provider seeding](#provider-seeding)).
|
|
336
|
+
3. **`export MY_API_KEY=…`** — only needed when the provider config references `"$MY_API_KEY"` and no credential is stored.
|
|
337
|
+
|
|
338
|
+
After editing `models.json`, run `/reload` or restart `pi` so every runtime picks it up.
|
|
339
|
+
|
|
340
|
+
### `the reviewer's provider failed after N retry attempt(s): …` in a Review Mode round
|
|
341
|
+
|
|
342
|
+
The warning reads `review skipped (the reviewer's provider failed after 3 retry attempt(s): 429 Too Many Requests …)` and the reviewer pane shows the provider's error text live. This is the reviewer's **model provider** failing mid-run — its API returned errors until Pi's auto-retry budget (`settings.retry.maxRetries`, default 3) ran out, so the run settled without ever reaching `review_submit_plan`. It is **not** a Review Mode fault and nothing was written; the author's plan is handed over unchanged.
|
|
343
|
+
|
|
344
|
+
Earlier versions mislabeled this as `the reviewer finished without submitting a plan — it has no write path…`; since 0.3.3 the real provider error is captured from the reviewer session and reported here, in the pane, and via `/review-status` (the old message now appears only when the reviewer genuinely settled without calling `review_submit_plan`).
|
|
345
|
+
|
|
346
|
+
What to do:
|
|
347
|
+
|
|
348
|
+
1. **Check the provider** — the truncated message is the provider's own reply (rate limit, quota, overloaded, auth). Retry the review with `/review` once the provider recovers; a review that dies this way can simply be re-run.
|
|
349
|
+
2. **A long review shares one API key with the parent** — planner + reviewer often hit the same key, so a long review can exhaust rate limits. Stagger heavy Plan Mode work or raise the provider's quota.
|
|
350
|
+
3. **Switch the reviewer model** if the failures recur — `/role set reviewer <provider/model>` (see [Review Mode](#review-mode)); pick a model whose provider is currently healthy.
|
|
351
|
+
|
|
235
352
|
## Development
|
|
236
353
|
|
|
237
354
|
```bash
|
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
|
+
}
|