@dieulc/workflow 0.4.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 CHANGED
@@ -12,6 +12,7 @@ Plan↔Build mode with an independent **Review Mode** reviewer, questionnaire, s
12
12
  - **Rewind** — shadow bare-repo checkpoints per cwd (`agent/checkpoints/checkpoint-<slug>.git`), `isDirty` + `createSafetySnapshot` + `restoreCode`
13
13
  - **Plannotator bridge** — `plannotator:request/review-result/plan-approved` events, inline markdown handoff (`ScrollView` → inline scrollback), `getUtcDatePrefix` / `normalizePlanPath` for `.pi/plans/<UTC-date>-<slug>.md`
14
14
  - **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all).
15
+ - **Model profiles** — named combinations of role models (`profiles.ts`, `~/.pi/agent/profiles.json` v1). Create a profile per provider or per budget, switch with `/profile` — activation is session-scoped, so two concurrent sessions can run different profiles. With no profile active the roles.json flow applies unchanged. See [Model Profiles](#model-profiles).
15
16
 
16
17
  ## Install
17
18
 
@@ -34,14 +35,42 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
34
35
  | `/todos` | Show todo list |
35
36
  | `/btw` | Queue note for next turn |
36
37
  | `/role` | Config screen: one model + thinking per role (planner/explorer/builder/reviewer tabs, custom roles included). CLI: `list`, `set <role> <provider/model> [thinking]`, `use <role\|auto>`, `add/remove/reset` |
38
+ | `/profile` | Switch or manage model profiles (named role-model combinations). Picker: activate / edit / rename / delete / New profile… / Default…. CLI: `list`, `show <name>`, `use <name\|none>`, `create <name>`, `edit <name>`, `rename <old> <new>`, `delete <name>`, `default [name\|none] [global\|project]` |
37
39
  | `/review-mode [on\|off] [global\|project]` | Enable/disable Review Mode (default `off`). `status` prints the effective config, naming the settings file that supplied the timeout |
38
40
  | `/review-mode timeout <seconds\|unlimited> [global\|project]` | Set or show the per-pass time cap (`unlimited`/`0` = no cap). Writes to global settings by default |
39
41
  | `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
40
42
  | `/review-status` | Show review phase, model, plan hash, findings and any error |
41
43
  | `/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 |
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 |
43
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`) |
44
46
 
47
+ ## Model Profiles
48
+
49
+ A profile is a **named combination of role model settings** — one `{provider, id, thinking}` each for planner, explorer, builder and reviewer. Profiles layer on top of Model Roles: role identity (tools, system prompts, custom roles) stays global in `roles.json`; a profile only overrides models while it is active.
50
+
51
+ ```text
52
+ /profile # picker: activate / edit / rename / delete / New profile… / Default…
53
+ /profile create budget # name it, then pick model + thinking per role (same flow as /role)
54
+ /profile use budget # activate for THIS session (none = back to the roles.json flow)
55
+ /profile edit budget # re-open the tabbed model editor
56
+ /profile rename budget cheap
57
+ /profile delete budget # confirm dialog; deleting the active profile falls back to base roles
58
+ /profile default budget # workflow.defaultProfile — new sessions start on it (global|project)
59
+ ```
60
+
61
+ ### Semantics
62
+
63
+ - **What a profile overrides**: model + thinking for the four built-in roles only. Tools, `systemPromptAddendum` and custom roles are global (roles.json) and are never touched by a profile. Partial profiles are allowed in hand-edited files — roles a profile does not name fall back to the base role model.
64
+ - **Activation is session-scoped.** `/profile use` changes only the current session and rides the session's `workflow` entry, so `/resume` restores it and **two concurrent sessions can run different profiles** — session 1 on a cheap provider, session 2 on a premium one. There is deliberately no global "active profile" file.
65
+ - **Optional default**: `workflow.defaultProfile` (see [Settings](#settings)) seeds sessions that never made an explicit choice; an explicit `/profile use <name|none>` always wins for that session.
66
+ - **Effect**: `/plan` switches to the profile's planner, `/build` to the profile's builder, `explore` subagents run on the profile's explorer, and Review Mode's reviewer session uses the profile's reviewer (planner fallback preserved). Activating while in plan/build re-applies the mode model immediately; in Default mode nothing switches until the next plan/build/explore/review use — the manual chat model is never touched by activation.
67
+ - **`/role` keeps editing the base roles.** With a profile active, the `/role` picker shows a notice and `/role list` prints the profile's overrides; `/profile edit <name>` is the way to change what actually runs.
68
+ - **Fallback**: no profiles (or none active) → the roles.json flow, identical to before profiles existed. A profile referencing a model missing from the registry is warned about at save time and refused at switch time ("staying on current model"), same as `/role`.
69
+
70
+ **Why**: mix models from different providers in one workflow (frontier planner + cheap explorer), keep one profile per provider to control budget per provider, run concurrent sessions on different providers to work around per-provider concurrency limits, and exploit cheaper starter plans across several providers.
71
+
72
+ Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace, project wins). Names are case-insensitively unique, ≤ 40 chars, no whitespace (keep them CLI-addressable), and `none`/`auto` are reserved. A missing file is the normal empty state; a corrupt or wrong-version file is never rewritten (warning + in-memory empty config).
73
+
45
74
  ## Review Mode
46
75
 
47
76
  An independent second agent supervises the plan before you see it. The flow is strictly **sequential**: Plan Mode finishes, then the reviewer runs, then the reviewed plan goes to Plannotator.
@@ -238,7 +267,7 @@ Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global
238
267
 
239
268
  ## Commit policy
240
269
 
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.
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.
242
271
 
243
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:
244
273
 
@@ -259,6 +288,16 @@ The default exists because prompt-only rules are soft: models habitually commit
259
288
 
260
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.
261
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
+
262
301
  ```text
263
302
  /commit-policy # status: mode, which scope supplied it, both settings paths
264
303
  /commit-policy never # set globally (~/.pi/agent/settings.json)
@@ -271,6 +310,20 @@ Known limits (accepted, same trade-off OpenCode/Claude Code make): a configured
271
310
 
272
311
  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`.
273
312
 
313
+ Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace). Each profile holds `{name, description, roles}` where `roles` maps any of the four built-in role names to one `{provider, id, thinking}` — see [Model Profiles](#model-profiles).
314
+
315
+ The default profile lives under `workflow.defaultProfile` (global/project precedence like the other workflow settings):
316
+
317
+ ```json
318
+ {
319
+ "workflow": {
320
+ "defaultProfile": "cheap"
321
+ }
322
+ }
323
+ ```
324
+
325
+ `/profile default <name|none> [global|project]` writes this key read-modify-write, preserving every other settings key. Sessions that never picked explicitly (fresh or resumed) re-read it at every start, so changing the default applies to them; an explicit `/profile use` in a session pins that session until changed.
326
+
274
327
  Review Mode settings live under `workflow.reviewMode`:
275
328
 
276
329
  | Key | Default | Meaning |
@@ -311,7 +364,7 @@ Commit Policy settings live under `workflow.commitPolicy` (same global/project p
311
364
 
312
365
  ## How it works
313
366
 
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.
367
+ 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), `profiles.ts` (profile registry, the model overlay and the `workflow.defaultProfile` setting — no Pi runtime needed, unit-testable), 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.
315
368
 
316
369
  ### Todo protocol (industry-aligned)
317
370
 
@@ -355,7 +408,7 @@ What to do:
355
408
  cd agent/extensions/workflow
356
409
  npm install
357
410
  npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
358
- npm test # node --test utils.todo.test.ts review.test.ts review-ui.test.ts
411
+ npm test # node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts
359
412
  ```
360
413
 
361
414
  Plain TypeScript, no build step — Pi loads `index.ts` directly (via jiti). From the repo root, `npm run verify` checks this package's manifest, tarball contents, tests and load path before a release. Live reload: edit the `.ts` file and run `/reload` in Pi.
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).