@dieulc/workflow 0.5.0 → 0.5.2

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
@@ -41,7 +41,7 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
41
41
  | `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
42
42
  | `/review-status` | Show review phase, model, plan hash, findings and any error |
43
43
  | `/review` | Run one review round against the current plan file now |
44
- | `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated |
44
+ | `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated; the confirmation arms the commit exemption for that run |
45
45
  | `/commit-policy [never\|ask\|allow] [global\|project]` | View or set the commit policy (default `never`: the agent cannot `git add`/`commit`/`push` — you commit, or use `/commit`) |
46
46
 
47
47
  ## Model Profiles
@@ -69,7 +69,9 @@ A profile is a **named combination of role model settings** — one `{provider,
69
69
 
70
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
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).
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`/`off` are reserved (`off` is a `/profile use` deactivate keyword, so a profile can never be named it). A missing file is the normal empty state; a corrupt or wrong-version file is never rewritten (warning + in-memory empty config).
73
+
74
+ **Argument completion** follows pi's full-argument contract (the same one pi's `/mcp` uses): accepting a suggestion replaces the entire typed argument text, so completing `/profile use Syn-␣` inserts `/profile use Syn-Deepseek-…` with the subcommand intact — it never collapses to `/profile <name>`. The dropdown closes (and the first Enter submits) once the typed token is already an exact, unique match. This applies positionally to `/profile` (`use|edit|show|delete|rename <old>|default <name> [global|project]`), `/commit-policy` (`never|ask|allow [global|project]`) and `/role` (`set <role> <provider/model> [thinking]`, `use <role|auto>`, `remove <name>`).
73
75
 
74
76
  ## Review Mode
75
77
 
@@ -267,7 +269,7 @@ Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global
267
269
 
268
270
  ## Commit policy
269
271
 
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 (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.
272
+ By default the agent **cannot touch your git history**: the tool gate deterministically blocks `git add`, `git commit` and `git push` — including compound commands (`git add -A && git commit -m x`), global-option forms (`git -C path add`) and `powershell` wrappers. You commit manually, or run `/commit` for an AI-summarized, confirmation-gated commit. Read-only git (`status`, `log`, `diff`, `branch -a`, `config --get`, …) stays free, and plan mode keeps its stricter read-only rules regardless.
271
273
 
272
274
  The default exists because prompt-only rules are soft: models habitually commit per plan step, and "never commit unless asked" instructions in CLAUDE.md-style files are famously ignored (anthropics/claude-code#58883, #68724, #7029). Claude Code, Codex CLI and OpenCode all treat agent-initiated commits as something to gate or approve — so the policy here is enforced where commands execute, not in prose:
273
275
 
@@ -288,6 +290,16 @@ The default exists because prompt-only rules are soft: models habitually commit
288
290
 
289
291
  The status line always shows the active policy (`▶ build 2/5 · commits:never`), so you can see at a glance what the agent may do to history.
290
292
 
293
+ ### `/commit` under any policy
294
+
295
+ `/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:
296
+
297
+ - `/commit` records a *pending flow* and injects its agent turn. Nothing is exempted yet.
298
+ - 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.
299
+ - 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`).
300
+
301
+ 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.
302
+
291
303
  ```text
292
304
  /commit-policy # status: mode, which scope supplied it, both settings paths
293
305
  /commit-policy never # set globally (~/.pi/agent/settings.json)
package/commit-policy.ts CHANGED
@@ -23,6 +23,8 @@
23
23
 
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
+ import type { ArgumentCompletion } from "./utils.ts";
27
+ import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
26
28
 
27
29
  /** Pi's project config directory. Mirrors WORKFLOW_CONFIG_DIR without importing review.ts (which pulls provider modules). */
28
30
  export const COMMIT_CONFIG_DIR = ".pi";
@@ -321,6 +323,176 @@ export function stepCommitDecision(
321
323
  };
322
324
  }
323
325
 
326
+ // ── /commit authorization (scoped exemption from the gate) ───────────
327
+ //
328
+ // The /commit flow is multi-turn (read diff → confirm via questionnaire →
329
+ // commit), so a single-turn exemption is always disarmed before the commit
330
+ // runs. Instead the flow starts a PendingCommitFlow and the authorization is
331
+ // armed only when the user CONFIRMS in the extension-owned questionnaire
332
+ // tool. The authorization is valid until the run settles, bounded by a TTL and
333
+ // a commit cap, and exempts ONLY `git commit` (never `add`/`push`).
334
+
335
+ /** How long a /commit flow may sit awaiting the user's decision. */
336
+ export const COMMIT_FLOW_TTL_MS = 30 * 60 * 1000;
337
+
338
+ /** How long an armed authorization stays valid (run-settle is the primary bound). */
339
+ export const COMMIT_AUTH_TTL_MS = 10 * 60 * 1000;
340
+
341
+ /** Defense-in-depth cap on commits made under one /commit authorization. */
342
+ export const COMMIT_AUTH_MAX_COMMITS = 10;
343
+
344
+ let commitFlowCounter = 0;
345
+
346
+ /** A /commit flow awaiting the user's confirmation. */
347
+ export interface PendingCommitFlow {
348
+ id: string;
349
+ startedAt: number;
350
+ expiresAt: number;
351
+ }
352
+
353
+ /** An armed exemption from the commit gate, created at the user's confirmation. */
354
+ export interface CommitAuthorizationState {
355
+ flowId: string;
356
+ armedAt: number;
357
+ expiresAt: number;
358
+ commits: number;
359
+ maxCommits: number;
360
+ }
361
+
362
+ /** Start a new /commit flow (id is unique within the process). */
363
+ export function startCommitFlow(
364
+ now: number,
365
+ ttlMs: number = COMMIT_FLOW_TTL_MS,
366
+ ): PendingCommitFlow {
367
+ commitFlowCounter += 1;
368
+ return {
369
+ id: `commit-${now}-${commitFlowCounter}`,
370
+ startedAt: now,
371
+ expiresAt: now + ttlMs,
372
+ };
373
+ }
374
+
375
+ /** Arm the exemption for one flow. Never throws. */
376
+ export function armCommitAuthorization(input: {
377
+ flowId: string;
378
+ now: number;
379
+ ttlMs?: number;
380
+ maxCommits?: number;
381
+ }): CommitAuthorizationState {
382
+ const ttlMs = input.ttlMs ?? COMMIT_AUTH_TTL_MS;
383
+ return {
384
+ flowId: input.flowId,
385
+ armedAt: input.now,
386
+ expiresAt: input.now + ttlMs,
387
+ commits: 0,
388
+ maxCommits: input.maxCommits ?? COMMIT_AUTH_MAX_COMMITS,
389
+ };
390
+ }
391
+
392
+ /** True only while an authorization is present, unexpired and under its cap. */
393
+ export function isAuthorizationActive(
394
+ auth: CommitAuthorizationState | null | undefined,
395
+ now: number,
396
+ ): boolean {
397
+ if (!auth) return false;
398
+ if (!(now < auth.expiresAt)) return false;
399
+ return auth.commits < auth.maxCommits;
400
+ }
401
+
402
+ /** A normalized questionnaire question (only the fields the detector needs). */
403
+ export interface CommitConfirmationQuestion {
404
+ question?: string;
405
+ header?: string;
406
+ options?: { label?: string; description?: string }[];
407
+ }
408
+
409
+ /**
410
+ * Is this questionnaire the /commit confirmation? Signals, in order: the
411
+ * "Commit" header, the canonical question text, or the "✅ Commit" option
412
+ * label. Deliberately tolerant — a missed match leaves the policy blocking
413
+ * (fail safe), so false negatives are a UX regression, false positives a risk.
414
+ */
415
+ export function isCommitConfirmation(
416
+ questions: readonly CommitConfirmationQuestion[] | undefined,
417
+ ): boolean {
418
+ if (!Array.isArray(questions)) return false;
419
+ return questions.some((q) => {
420
+ if (!q || typeof q !== "object") return false;
421
+ if (typeof q.header === "string" && /^commit$/i.test(q.header.trim())) {
422
+ return true;
423
+ }
424
+ if (
425
+ typeof q.question === "string" &&
426
+ /commit these staged changes/i.test(q.question)
427
+ ) {
428
+ return true;
429
+ }
430
+ return (q.options ?? []).some(
431
+ (o: { label?: string; description?: string }) =>
432
+ typeof o?.label === "string" && /✅\s*commit/i.test(o.label),
433
+ );
434
+ });
435
+ }
436
+
437
+ /** One questionnaire answer, as far as the decision needs to know. */
438
+ export interface CommitConfirmationAnswer {
439
+ answer?: string;
440
+ wasCustom?: boolean;
441
+ }
442
+
443
+ /**
444
+ * Did the user authorize the commit? Cancel (`cancelled` or a ❌/cancel-only
445
+ * label) never arms. Freeform text always arms (it is the user's own commit
446
+ * message) unless it is exactly a cancel token. A canned label arms unless it
447
+ * reads as cancel without commit.
448
+ */
449
+ export function isPositiveCommitDecision(input: {
450
+ cancelled?: boolean;
451
+ answer?: CommitConfirmationAnswer;
452
+ }): boolean {
453
+ if (input?.cancelled) return false;
454
+ const answer = input?.answer;
455
+ if (!answer) return false;
456
+ const label = String(answer.answer ?? "").trim();
457
+ if (!label) return false;
458
+ const isBareCancel = /^(❌\s*)?cancel[.!]?$/i.test(label);
459
+ if (isBareCancel) return false;
460
+ if (answer.wasCustom) return true;
461
+ if (/\bcancel\b/i.test(label) && !/\bcommit\b/i.test(label)) return false;
462
+ return true;
463
+ }
464
+
465
+ /** The commit-policy gate outcome for one tool call. */
466
+ export type CommitGateDecision = "allow" | "block" | "ask";
467
+
468
+ /** Git subcommands the commit policy gates (mirrors permissions.ts CommitPolicyHit). */
469
+ export type CommitGateHit = "add" | "commit" | "push";
470
+
471
+ export interface CommitGateInput {
472
+ policy: CommitPolicyMode;
473
+ hit: CommitGateHit;
474
+ hasTodos: boolean;
475
+ auth?: CommitAuthorizationState | null;
476
+ now: number;
477
+ }
478
+
479
+ /**
480
+ * The pure commit-policy decision. Ordering mirrors the original inline gate:
481
+ * an `allow` policy with no active plan owns nothing → allow; `ask` keeps
482
+ * fresh staging free; an armed authorization exempts ONLY `git commit`; then
483
+ * never/allow block and anything left (`ask`) prompts. Never throws.
484
+ */
485
+ export function decideCommitGate(input: CommitGateInput): CommitGateDecision {
486
+ const { policy, hit, hasTodos } = input;
487
+ if (policy === "allow" && !hasTodos) return "allow";
488
+ if (policy === "ask" && hit === "add") return "allow";
489
+ if (hit === "commit" && isAuthorizationActive(input.auth, input.now)) {
490
+ return "allow";
491
+ }
492
+ if (policy === "never" || policy === "allow") return "block";
493
+ return "ask";
494
+ }
495
+
324
496
  /**
325
497
  * Read-modify-write `workflow.commitPolicy` into a settings file, preserving
326
498
  * every other key in that file (Pi owns settings.json — we must not clobber it).
@@ -356,3 +528,104 @@ export function writeCommitPolicyConfig(
356
528
  };
357
529
  }
358
530
  }
531
+
532
+ // ── Argument completions (/commit-policy) ────────────────────────────
533
+
534
+ /** One /commit-policy subcommand with its usage hint. */
535
+ const COMMIT_POLICY_SUBS: readonly {
536
+ name: string;
537
+ label: string;
538
+ description: string;
539
+ }[] = [
540
+ {
541
+ name: "never",
542
+ label: "never",
543
+ description: "agent cannot git add/commit/push — you commit (or /commit)",
544
+ },
545
+ {
546
+ name: "ask",
547
+ label: "ask",
548
+ description: "agent may commit; each git commit/push asks first",
549
+ },
550
+ {
551
+ name: "allow",
552
+ label: "allow",
553
+ description: "agent may commit freely (per-step during plan execution)",
554
+ },
555
+ ];
556
+
557
+ const COMMIT_POLICY_SCOPES: readonly {
558
+ token: string;
559
+ description: string;
560
+ }[] = [
561
+ { token: "global", description: "write to ~/.pi/agent/settings.json" },
562
+ { token: "project", description: "write to <cwd>/.pi/settings.json" },
563
+ ];
564
+
565
+ /** commit-policy subcommands take an optional scope → trailing-space values. */
566
+ const COMMIT_POLICY_MODE_TOKENS: ReadonlySet<string> = new Set([
567
+ "never",
568
+ "ask",
569
+ "allow",
570
+ ]);
571
+
572
+ /**
573
+ * Argument completions for `/commit-policy` (pure; unit-tested in
574
+ * commit-policy.test.ts).
575
+ *
576
+ * Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
577
+ * /mcp extension): the first token completes a mode, the second token
578
+ * completes the write scope, and every returned `value` is the complete
579
+ * replacement for the typed argument text (`"ask project"`, never
580
+ * `"project"`). Returns null when nothing useful can be offered, including
581
+ * when the typed token is already an exact unique match (exact-match
582
+ * suppression — the dropdown closes so the first Enter submits).
583
+ */
584
+ export function commitPolicyArgumentCompletions(
585
+ argumentPrefix: string,
586
+ ): ArgumentCompletion[] | null {
587
+ const raw = String(argumentPrefix ?? "");
588
+ const join = (token: string) => joinArgumentValue(raw, token);
589
+
590
+ // ── Subcommand position: no whitespace typed yet ────────────────────
591
+ if (!raw.trim() || !/\s/.test(raw)) {
592
+ const lower = raw.trim().toLowerCase();
593
+ const matching = COMMIT_POLICY_SUBS.filter((s) =>
594
+ s.name.startsWith(lower),
595
+ );
596
+ if (matching.length === 0) return null;
597
+ return matching.map((s) => ({
598
+ value: `${s.name} `,
599
+ label: s.label,
600
+ description: s.description,
601
+ }));
602
+ }
603
+
604
+ // ── Argument position: mode already committed → complete the scope ──
605
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
606
+ const mode = (tokens[0] ?? "").toLowerCase();
607
+ if (!COMMIT_POLICY_MODE_TOKENS.has(mode)) return null;
608
+ const endsWithWs = /\s$/.test(raw);
609
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
610
+ const items: ArgumentCompletion[] = COMMIT_POLICY_SCOPES.flatMap((s) =>
611
+ s.token.startsWith(partial.toLowerCase())
612
+ ? [
613
+ {
614
+ value: join(s.token),
615
+ label: s.token,
616
+ description: s.description,
617
+ },
618
+ ]
619
+ : [],
620
+ );
621
+ if (items.length === 0) return null;
622
+ if (
623
+ suppressExactArgumentMatch(
624
+ items.map((i) => i.label),
625
+ partial,
626
+ )
627
+ ) {
628
+ return null;
629
+ }
630
+ return items;
631
+ }
package/index.ts CHANGED
@@ -89,12 +89,25 @@ import {
89
89
  type WorkflowRole,
90
90
  } from "./permissions.ts";
91
91
  import {
92
+ COMMIT_AUTH_MAX_COMMITS,
93
+ COMMIT_AUTH_TTL_MS,
94
+ COMMIT_FLOW_TTL_MS,
95
+ armCommitAuthorization,
92
96
  commitConfigPaths,
97
+ commitPolicyArgumentCompletions,
98
+ decideCommitGate,
93
99
  describeCommitPolicyMode,
100
+ isAuthorizationActive,
101
+ isCommitConfirmation,
102
+ isPositiveCommitDecision,
94
103
  readCommitPolicyConfig,
104
+ startCommitFlow,
95
105
  stepCommitDecision,
96
106
  writeCommitPolicyConfig,
107
+ type CommitAuthorizationState,
108
+ type CommitGateHit,
97
109
  type CommitPolicyMode,
110
+ type PendingCommitFlow,
98
111
  type StepCommitCandidate,
99
112
  } from "./commit-policy.ts";
100
113
  import {
@@ -110,6 +123,7 @@ import {
110
123
  defaultRoleConfig,
111
124
  loadRoleConfigDetailed,
112
125
  rolesFingerprint,
126
+ roleArgumentCompletions,
113
127
  saveRoleConfig,
114
128
  getRole,
115
129
  formatRolesForDisplay,
@@ -133,6 +147,7 @@ import {
133
147
  getProfile,
134
148
  formatProfilesForDisplay,
135
149
  describeProfile,
150
+ profileArgumentCompletions,
136
151
  validateProfileName,
137
152
  applyProfileOverlay,
138
153
  missingProfileRoles,
@@ -400,11 +415,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
400
415
  let todoItems: TodoItem[] = [];
401
416
  // Plan file the current todo list was derived from (drives merge vs replace).
402
417
  let todoPlanPath: string | undefined;
403
- // Armed while the user-invoked /commit flow's agent turn is running: its
404
- // `git commit` bypasses the commit-policy gate (staging already happened
405
- // extension-side via piExecGit, outside the tool gate). Cleared on
406
- // turn_end/agent_settled so the window stays one turn wide.
407
- let commitBypassActive = false;
418
+ // ── /commit flow authorization ──
419
+ // The /commit flow is multi-turn (read diff → confirm → commit), so a
420
+ // single-turn exemption is always disarmed before the commit runs. Instead:
421
+ // pendingCommitFlow — set when /commit injects its agent turn, cleared on
422
+ // the user's confirmation (or a negative/cancel answer),
423
+ // so only THIS flow's questionnaire can arm.
424
+ // commitAuthorization — armed only when the user confirms in that
425
+ // questionnaire; exempts ONLY `git commit` (never
426
+ // add/push) until the run settles, bounded by a TTL
427
+ // and a commit cap.
428
+ // Both are cleared on agent_settled, session_shutdown and session_tree.
429
+ let pendingCommitFlow: PendingCommitFlow | null = null;
430
+ let commitAuthorization: CommitAuthorizationState | null = null;
408
431
  // ── Deterministic per-step commits (allow mode) ──
409
432
  // In 'allow' mode the EXTENSION — not the model — owns commit timing:
410
433
  // exactly one commit per batch of newly completed plan steps, made at the
@@ -1567,39 +1590,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
1567
1590
  );
1568
1591
  updateStatus(ctx as any);
1569
1592
  },
1570
- getArgumentCompletions: (prefix: string) => {
1571
- const flags = [
1572
- {
1573
- value: "never",
1574
- label: "never",
1575
- description:
1576
- "agent cannot git add/commit/push — you commit (or /commit)",
1577
- },
1578
- {
1579
- value: "ask",
1580
- label: "ask",
1581
- description: "agent may commit; each git commit/push asks first",
1582
- },
1583
- {
1584
- value: "allow",
1585
- label: "allow",
1586
- description:
1587
- "agent may commit freely (per-step during plan execution)",
1588
- },
1589
- {
1590
- value: "global",
1591
- label: "global",
1592
- description: "write to ~/.pi/agent/settings.json",
1593
- },
1594
- {
1595
- value: "project",
1596
- label: "project",
1597
- description: "write to <cwd>/.pi/settings.json",
1598
- },
1599
- ];
1600
- const filtered = flags.filter((f) => f.value.startsWith(prefix));
1601
- return filtered.length ? filtered : null;
1602
- },
1593
+ // pi-tui full-argument contract (see commitPolicyArgumentCompletions):
1594
+ // modes first, then the write scope, with values that replace the whole
1595
+ // typed argument text ("ask project", never bare "project").
1596
+ getArgumentCompletions: (prefix: string) =>
1597
+ commitPolicyArgumentCompletions(prefix),
1603
1598
  });
1604
1599
 
1605
1600
  pi.registerCommand("review-prompt", {
@@ -2532,16 +2527,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
2532
2527
 
2533
2528
  // Inject a user message → triggers an agent turn that proposes + confirms + commits
2534
2529
  const message = buildCommitPrompt(statSummary, stageAll);
2535
- // Arm the commit-policy bypass for exactly this agent turn: its `git
2536
- // commit` must run even under policy 'never'. Disarmed on turn_end /
2537
- // agent_settled, so the window is one turn wide.
2538
- commitBypassActive = true;
2539
- if ((pi as any).sendUserMessage) {
2540
- (pi as any).sendUserMessage(message);
2541
- } else {
2542
- (pi as any).sendMessage?.(
2543
- { customType: "workflow-commit", content: message, display: true },
2544
- { triggerTurn: true, deliverAs: "followUp" },
2530
+ // Start a commit flow. The exemption is NOT armed here: it is armed only
2531
+ // when the user confirms in THIS flow's questionnaire (see the
2532
+ // questionnaire tool), so an unconfirmed commit stays blocked under any
2533
+ // policy. A flow TTL bounds an abandoned confirmation.
2534
+ pendingCommitFlow = startCommitFlow(Date.now(), COMMIT_FLOW_TTL_MS);
2535
+ commitAuthorization = null;
2536
+ try {
2537
+ if ((pi as any).sendUserMessage) {
2538
+ await (pi as any).sendUserMessage(message);
2539
+ } else {
2540
+ await (pi as any).sendMessage?.(
2541
+ { customType: "workflow-commit", content: message, display: true },
2542
+ { triggerTurn: true, deliverAs: "followUp" },
2543
+ );
2544
+ }
2545
+ } catch (e: any) {
2546
+ // The turn never started → no questionnaire can arm; do not leave a
2547
+ // stale flow behind.
2548
+ pendingCommitFlow = null;
2549
+ ctx.ui.notify(
2550
+ `Failed to start the commit flow: ${String(e?.message ?? e ?? e)}`,
2551
+ "error",
2545
2552
  );
2546
2553
  }
2547
2554
  }
@@ -3010,6 +3017,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
3010
3017
 
3011
3018
  pi.registerCommand("role", {
3012
3019
  description: "Configure one model + thinking per role — opens picker UI",
3020
+ // pi-tui full-argument contract (see roleArgumentCompletions): positional
3021
+ // set/use/remove completions whose values replace the whole typed
3022
+ // argument text ("set planner <provider/model>"), never bare tokens.
3023
+ getArgumentCompletions: (prefix: string) => {
3024
+ // No ctx here — use the live session cwd so project-scope roles.json is
3025
+ // honored, matching the handler's ensureRoleConfig(cwd) key.
3026
+ ensureRoleConfig((currentSessionCtx as any)?.cwd);
3027
+ const models = collectRoleCatalog(currentSessionCtx as any).all.map(
3028
+ (e) => formatModelRef({ provider: e.provider, id: e.id } as RoleModel),
3029
+ );
3030
+ return roleArgumentCompletions(prefix, {
3031
+ roleNames: roleConfig.roles.map((r) => r.name),
3032
+ models,
3033
+ });
3034
+ },
3013
3035
  handler: async (args, ctx) => {
3014
3036
  const cwd = (ctx as any)?.cwd as string | undefined;
3015
3037
  ensureRoleConfig(cwd);
@@ -4521,90 +4543,31 @@ export default function workflowExtension(pi: ExtensionAPI) {
4521
4543
  return;
4522
4544
  }
4523
4545
 
4524
- // Bare profile name → show detail (mirrors /role <name>).
4546
+ // Bare profile name → show detail (mirrors /role <name>). The hint
4547
+ // matters: this branch is where the old completion bug dropped users
4548
+ // who meant `/profile use <name>`.
4525
4549
  const named = getProfile(profilesConfig, sub);
4526
4550
  if (named) {
4527
- ctx.ui.notify(describeProfile(named), "info");
4551
+ ctx.ui.notify(
4552
+ `${describeProfile(named)}\n\nActivate it with /profile use ${named.name}.`,
4553
+ "info",
4554
+ );
4528
4555
  return;
4529
4556
  }
4530
4557
  ctx.ui.notify(PROFILE_USAGE, "info");
4531
4558
  },
4559
+ // pi-tui contract: argument completions replace the ENTIRE typed argument
4560
+ // text with item.value (see profileArgumentCompletions / utils.joinArgument
4561
+ // Value). The old inline completer returned bare profile names, so
4562
+ // accepting a suggestion rewrote "/profile use X" → "/profile X" and the
4563
+ // command silently showed details instead of activating.
4532
4564
  getArgumentCompletions: (prefix: string) => {
4533
- ensureProfilesConfig();
4534
- const subs = [
4535
- { value: "list", label: "list", description: "show all profiles" },
4536
- {
4537
- value: "use",
4538
- label: "use <name|none>",
4539
- description: "activate/deactivate for this session",
4540
- },
4541
- {
4542
- value: "create",
4543
- label: "create <name>",
4544
- description: "create a profile (seeded from current models)",
4545
- },
4546
- {
4547
- value: "edit",
4548
- label: "edit <name>",
4549
- description: "open the tabbed model editor",
4550
- },
4551
- {
4552
- value: "rename",
4553
- label: "rename <old> <new>",
4554
- description: "rename a profile",
4555
- },
4556
- {
4557
- value: "delete",
4558
- label: "delete <name>",
4559
- description: "delete a profile",
4560
- },
4561
- {
4562
- value: "default",
4563
- label: "default [name|none] [global|project]",
4564
- description: "default profile for new sessions",
4565
- },
4566
- {
4567
- value: "show",
4568
- label: "show <name>",
4569
- description: "show one profile's models",
4570
- },
4571
- ];
4572
- const lower = String(prefix || "").toLowerCase();
4573
- if (lower.includes(" ")) {
4574
- // Completing an argument after a subcommand → profile names where relevant.
4575
- const cmd = lower.trim().split(/\s+/)[0];
4576
- const wantsNames =
4577
- cmd === "use" ||
4578
- cmd === "edit" ||
4579
- cmd === "delete" ||
4580
- cmd === "show" ||
4581
- cmd === "default";
4582
- if (!wantsNames) return null;
4583
- const last = lower.trim().split(/\s+/).pop() ?? "";
4584
- const names = profilesConfig.profiles
4585
- .filter((p) => p.name.toLowerCase().startsWith(last))
4586
- .map((p) => ({
4587
- value: p.name,
4588
- label: p.name,
4589
- description: p.description || undefined,
4590
- }));
4591
- return cmd === "use" || cmd === "default"
4592
- ? [
4593
- ...names,
4594
- { value: "none", label: "none", description: "no profile" },
4595
- ]
4596
- : names;
4597
- }
4598
- const matching = subs.filter((s) => s.value.startsWith(lower));
4599
- if (matching.length > 0) return matching;
4600
- const names = profilesConfig.profiles
4601
- .filter((p) => p.name.toLowerCase().startsWith(lower))
4602
- .map((p) => ({
4603
- value: p.name,
4604
- label: p.name,
4605
- description: p.description || undefined,
4606
- }));
4607
- return names.length ? names : null;
4565
+ // The completion callback gets no ctx — use the live session cwd so
4566
+ // project-scope profiles (<cwd>/.pi/profiles.json) are included and the
4567
+ // profiles cache key matches the handler's (a cwd-less reload could
4568
+ // otherwise deactivate an active project-scope profile).
4569
+ ensureProfilesConfig((currentSessionCtx as any)?.cwd);
4570
+ return profileArgumentCompletions(prefix, profilesConfig.profiles);
4608
4571
  },
4609
4572
  });
4610
4573
 
@@ -5801,6 +5764,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
5801
5764
  overlayActive = false;
5802
5765
  }
5803
5766
 
5767
+ // /commit authorization: arm the commit-policy exemption ONLY when the
5768
+ // user confirms THIS flow's commit questionnaire. A cancel (or any
5769
+ // non-confirmation questionnaire) clears the pending flow and arms
5770
+ // nothing, so the policy keeps blocking. Headless never reaches here
5771
+ // (early return above), which is the intended fail-closed behaviour.
5772
+ if (pendingCommitFlow && isCommitConfirmation(questions)) {
5773
+ const cancelled = !result || result.cancelled === true;
5774
+ const answer =
5775
+ !result || result.cancelled ? undefined : result.answers?.[0];
5776
+ if (isPositiveCommitDecision({ cancelled, answer })) {
5777
+ commitAuthorization = armCommitAuthorization({
5778
+ flowId: pendingCommitFlow.id,
5779
+ now: Date.now(),
5780
+ ttlMs: COMMIT_AUTH_TTL_MS,
5781
+ maxCommits: COMMIT_AUTH_MAX_COMMITS,
5782
+ });
5783
+ try {
5784
+ ctx.ui.notify?.(
5785
+ "Commit authorized — this run may commit the staged changes.",
5786
+ "info",
5787
+ );
5788
+ } catch (_e) {
5789
+ void _e;
5790
+ }
5791
+ }
5792
+ pendingCommitFlow = null;
5793
+ }
5794
+
5804
5795
  if (!result || result.cancelled) {
5805
5796
  return {
5806
5797
  content: [{ type: "text", text: "User cancelled questionnaire" }],
@@ -6421,6 +6412,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
6421
6412
  }
6422
6413
  ctx.ui.setStatus("workflow", undefined);
6423
6414
  ctx.ui.setWidget("workflow-todos", undefined);
6415
+ // Drop any /commit authorization or flow: a reload/quit must never leave
6416
+ // an exemption armed for whatever runs next.
6417
+ commitAuthorization = null;
6418
+ pendingCommitFlow = null;
6424
6419
  // Release the reviewer session (and its custom tools) on shutdown so a
6425
6420
  // reload or quit never leaves an orphaned in-process session behind.
6426
6421
  await teardownReview();
@@ -6432,6 +6427,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
6432
6427
  todoItems = [];
6433
6428
  todoPlanPath = undefined;
6434
6429
  btwNotes = [];
6430
+ // A rewind must not preserve a /commit exemption across branches.
6431
+ commitAuthorization = null;
6432
+ pendingCommitFlow = null;
6435
6433
  try {
6436
6434
  const branch = ctx.sessionManager.getBranch();
6437
6435
  for (const e of branch as any[]) {
@@ -6468,32 +6466,52 @@ export default function workflowExtension(pi: ExtensionAPI) {
6468
6466
  * allow → automatic per-step commits while a plan todo list is active
6469
6467
  * (the extension owns commit timing — model-initiated git add/commit/push
6470
6468
  * are blocked); outside a plan the model may commit as before. The
6471
- * user-invoked /commit flow arms `commitBypassActive` so its own `git
6472
- * commit` runs (staging is extension-side; `push` and `add` are never
6473
- * bypassed).
6469
+ * user-invoked /commit flow arms `commitAuthorization` ONLY when the user
6470
+ * confirms its questionnaire; that exemption covers just `git commit` for
6471
+ * the rest of the run (staging is extension-side; `push` and `add` are
6472
+ * never exempted).
6473
+ *
6474
+ * The decision itself is the pure `decideCommitGate`; this wrapper supplies
6475
+ * the runtime inputs and renders the block reasons / ask overlay.
6474
6476
  */
6475
6477
  async function commitPolicyGate(
6476
6478
  event: any,
6477
6479
  ctx: any,
6478
6480
  cwd: string,
6479
6481
  ): Promise<{ block: true; reason: string } | undefined> {
6480
- const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
6481
- // allow outside a plan: no per-step automation to own commit timing, so
6482
- // the model keeps its previous freedom.
6483
- if (policy === "allow" && todoItems.length === 0) return undefined;
6484
6482
  const cmd = String(event?.input?.command ?? "");
6485
6483
  const hit = gitCommitPolicyHit(cmd);
6486
6484
  if (!hit) return undefined;
6487
- // `add` is only gated in `never` mode; `ask` prompts on commit/push only
6488
- // (staging alone pollutes nothing).
6489
- if (policy === "ask" && hit === "add") return undefined;
6490
- // /commit bypass: only the flow's own `git commit` is exempt — `add` and
6491
- // `push` are not.
6492
- if (commitBypassActive && hit === "commit") {
6485
+ const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
6486
+ const now = Date.now();
6487
+ const decision = decideCommitGate({
6488
+ policy,
6489
+ hit: hit as CommitGateHit,
6490
+ hasTodos: todoItems.length > 0,
6491
+ auth: commitAuthorization,
6492
+ now,
6493
+ });
6494
+ if (decision === "allow") {
6495
+ // Count a granted `git commit` against the authorization, but only when
6496
+ // the authorization was actually the deciding factor (never/ask, or
6497
+ // allow-with-an-active-plan). `allow` outside a plan is free regardless.
6498
+ const authWasDecisive =
6499
+ hit === "commit" &&
6500
+ commitAuthorization !== null &&
6501
+ !(policy === "allow" && todoItems.length === 0);
6502
+ if (authWasDecisive && commitAuthorization) {
6503
+ const auth = commitAuthorization;
6504
+ if (!isAuthorizationActive(auth, now)) {
6505
+ commitAuthorization = null;
6506
+ } else {
6507
+ const next = { ...auth, commits: auth.commits + 1 };
6508
+ commitAuthorization = next.commits >= next.maxCommits ? null : next;
6509
+ }
6510
+ }
6493
6511
  return undefined;
6494
6512
  }
6495
6513
 
6496
- if (policy === "never" || policy === "allow") {
6514
+ if (decision === "block") {
6497
6515
  return {
6498
6516
  block: true,
6499
6517
  reason:
@@ -6507,7 +6525,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
6507
6525
  cmd,
6508
6526
  };
6509
6527
  }
6510
- // policy === "ask" → confirmation overlay. Headless has no one to ask →
6528
+ // decision === "ask" → confirmation overlay. Headless has no one to ask →
6511
6529
  // fail closed rather than commit unbidden.
6512
6530
  const ui = ctx?.ui;
6513
6531
  if (!ui || !ctx.hasUI) {
@@ -6886,9 +6904,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
6886
6904
  });
6887
6905
 
6888
6906
  pi.on("agent_settled", async () => {
6889
- // Belt-and-braces: the run is over (completed, aborted or errored) — the
6890
- // /commit bypass must not leak into whatever runs next.
6891
- commitBypassActive = false;
6907
+ // The run is over (completed, aborted or errored). The /commit flow lives
6908
+ // within one run, so both the armed exemption and any unconfirmed flow are
6909
+ // dropped here — nothing that runs next inherits them.
6910
+ commitAuthorization = null;
6911
+ pendingCommitFlow = null;
6892
6912
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
6893
6913
  });
6894
6914
 
@@ -7067,10 +7087,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
7067
7087
 
7068
7088
  // Track progress (all modes, whenever todos exist)
7069
7089
  pi.on("turn_end", async (event, ctx) => {
7070
- // Disarm the /commit bypass as soon as any turn ends: the /commit flow's
7071
- // `git commit` runs as a tool call WITHIN its turn, so this never disarms
7072
- // too early — and nothing after the turn inherits the exemption.
7073
- commitBypassActive = false;
7090
+ // NOTE: do NOT disarm the /commit authorization here. `turn_end` fires
7091
+ // after EVERY assistant response, but /commit is multi-turn (read diff →
7092
+ // confirm → commit), so disarming per turn kills the exemption before the
7093
+ // commit turn. Lifetime is the run: `agent_settled` clears it.
7074
7094
  if (todoItems.length === 0) return;
7075
7095
  if (!isAssistantMessage(event.message as AgentMessage)) return;
7076
7096
  const text = getTextContent(event.message as AssistantMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
package/profiles.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  roleIcon,
41
41
  } from "./roles.ts";
42
42
  import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
43
+ import type { ArgumentCompletion } from "./utils.ts";
44
+ import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
43
45
 
44
46
  // ── Types ────────────────────────────────────────────────────────────
45
47
 
@@ -66,7 +68,7 @@ export const PROFILE_CONFIG_VERSION = 1;
66
68
  export const MAX_PROFILE_NAME_LENGTH = 40;
67
69
 
68
70
  /** Reserved words — they collide with `/profile use <name|none>` and `/role use` style keywords. */
69
- export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto"];
71
+ export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto", "off"];
70
72
 
71
73
  /** Fallback thinking level when a sanitized model omits an invalid one. */
72
74
  const DEFAULT_THINKING: ThinkingLevel = "medium";
@@ -615,3 +617,208 @@ export function writeDefaultProfileSetting(
615
617
  };
616
618
  }
617
619
  }
620
+
621
+ // ── Argument completions (/profile) ──────────────────────────────────
622
+
623
+ /** Subcommands that take a profile-name argument (position 2). */
624
+ const PROFILE_NAME_SUBS: ReadonlySet<string> = new Set([
625
+ "use",
626
+ "edit",
627
+ "show",
628
+ "delete",
629
+ "default",
630
+ "rename",
631
+ ]);
632
+
633
+ /** Aliases the /profile handler accepts, mapped to their canonical subcommand. */
634
+ const PROFILE_CMD_ALIASES: Readonly<Record<string, string>> = {
635
+ activate: "use",
636
+ config: "edit",
637
+ remove: "delete",
638
+ rm: "delete",
639
+ mv: "rename",
640
+ new: "create",
641
+ add: "create",
642
+ };
643
+
644
+ /** Write-scope descriptions for `/profile default <name> <scope>` completions. */
645
+ const SCOPE_DESCRIPTIONS: Readonly<Record<string, string>> = {
646
+ global: "write to ~/.pi/agent/settings.json",
647
+ project: "write to <cwd>/.pi/settings.json",
648
+ };
649
+
650
+ interface ProfileSubMeta {
651
+ name: string;
652
+ /** Takes an argument → completion `value` carries a trailing space. */
653
+ takesArg: boolean;
654
+ label: string;
655
+ description: string;
656
+ }
657
+
658
+ const PROFILE_SUBS: readonly ProfileSubMeta[] = [
659
+ {
660
+ name: "list",
661
+ takesArg: false,
662
+ label: "list",
663
+ description: "show all profiles",
664
+ },
665
+ {
666
+ name: "use",
667
+ takesArg: true,
668
+ label: "use <name|none>",
669
+ description: "activate/deactivate for this session",
670
+ },
671
+ {
672
+ name: "create",
673
+ takesArg: true,
674
+ label: "create <name>",
675
+ description: "create a profile (seeded from current models)",
676
+ },
677
+ {
678
+ name: "edit",
679
+ takesArg: true,
680
+ label: "edit <name>",
681
+ description: "open the tabbed model editor",
682
+ },
683
+ {
684
+ name: "rename",
685
+ takesArg: true,
686
+ label: "rename <old> <new>",
687
+ description: "rename a profile",
688
+ },
689
+ {
690
+ name: "delete",
691
+ takesArg: true,
692
+ label: "delete <name>",
693
+ description: "delete a profile",
694
+ },
695
+ {
696
+ name: "default",
697
+ takesArg: true,
698
+ label: "default [name|none] [global|project]",
699
+ description: "default profile for new sessions",
700
+ },
701
+ {
702
+ name: "show",
703
+ takesArg: true,
704
+ label: "show <name>",
705
+ description: "show one profile's models",
706
+ },
707
+ ];
708
+
709
+ /** Profile names matching a lowercased partial token, case-insensitively. */
710
+ function profileNameMatches(
711
+ profiles: readonly Profile[],
712
+ partial: string,
713
+ ): Profile[] {
714
+ if (!partial) return [...profiles];
715
+ return profiles.filter((p) =>
716
+ p.name.toLowerCase().startsWith(partial.toLowerCase()),
717
+ );
718
+ }
719
+
720
+ function profileCompletions(
721
+ profiles: readonly Profile[],
722
+ join: (token: string) => string,
723
+ ): ArgumentCompletion[] {
724
+ return profiles.map((p) => ({
725
+ value: join(p.name),
726
+ label: p.name,
727
+ ...(p.description ? { description: p.description } : {}),
728
+ }));
729
+ }
730
+
731
+ /**
732
+ * Argument completions for `/profile` (pure; unit-tested in profiles.test.ts).
733
+ *
734
+ * `argumentPrefix` is the ENTIRE typed argument text after "/profile " (what
735
+ * pi's CombinedAutocompleteProvider passes). Per the pi-tui contract, every
736
+ * returned item's `value` is the complete replacement for that argument text
737
+ * (`joinArgumentValue`), never a bare token — accepting `"use Syn-De" →
738
+ * "Syn-…"` would otherwise drop the `use` subcommand (see the /mcp
739
+ * extension for the reference implementation).
740
+ *
741
+ * Returns null when there is nothing useful to offer (unknown subcommand,
742
+ * `rename <old> <new>` second arg, `create`, …) or when the typed token is
743
+ * already an exact, unique match (exact-match suppression → the dropdown
744
+ * closes and the first Enter submits the command).
745
+ */
746
+ export function profileArgumentCompletions(
747
+ argumentPrefix: string,
748
+ profiles: readonly Profile[],
749
+ ): ArgumentCompletion[] | null {
750
+ const raw = String(argumentPrefix ?? "");
751
+ const join = (token: string) => joinArgumentValue(raw, token);
752
+
753
+ // ── Subcommand position: no whitespace typed yet ────────────────────
754
+ if (!raw.trim() || !/\s/.test(raw)) {
755
+ const lower = raw.trim().toLowerCase();
756
+ const matching = PROFILE_SUBS.filter((s) =>
757
+ s.name.startsWith(lower),
758
+ );
759
+ if (matching.length > 0) {
760
+ return matching.map((s) => ({
761
+ value: s.takesArg ? `${s.name} ` : s.name,
762
+ label: s.label,
763
+ description: s.description,
764
+ }));
765
+ }
766
+ // Bare profile name → `/profile <name>` shows detail (mirrors /role).
767
+ const names = profileNameMatches(profiles, lower);
768
+ if (names.length === 0) return null;
769
+ return profileCompletions(names, join);
770
+ }
771
+
772
+ // ── Argument position ────────────────────────────────────────────────
773
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
774
+ const head = tokens[0] ?? "";
775
+ const canonical = PROFILE_CMD_ALIASES[head.toLowerCase()] ?? head.toLowerCase();
776
+ const endsWithWs = /\s$/.test(raw);
777
+ const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
778
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
779
+
780
+ let items: ArgumentCompletion[] | null = null;
781
+
782
+ if (canonical === "default") {
783
+ if (committedCount <= 1) {
784
+ // Second token: profile name | none.
785
+ const names = profileNameMatches(profiles, partial);
786
+ const none: ArgumentCompletion[] =
787
+ "none".startsWith(partial.toLowerCase())
788
+ ? [{ value: join("none"), label: "none", description: "no profile" }]
789
+ : [];
790
+ items = [...profileCompletions(names, join), ...none];
791
+ } else {
792
+ // Third token: scope.
793
+ items = ["global", "project"].flatMap((s) =>
794
+ s.startsWith(partial.toLowerCase())
795
+ ? [
796
+ {
797
+ value: join(s),
798
+ label: s,
799
+ description: SCOPE_DESCRIPTIONS[s],
800
+ },
801
+ ]
802
+ : [],
803
+ );
804
+ }
805
+ } else if (canonical === "rename") {
806
+ // First arg: existing name; second arg (new name) is free text.
807
+ if (committedCount <= 1) {
808
+ items = profileCompletions(profileNameMatches(profiles, partial), join);
809
+ }
810
+ } else if (PROFILE_NAME_SUBS.has(canonical)) {
811
+ const names = profileNameMatches(profiles, partial);
812
+ const none: ArgumentCompletion[] =
813
+ canonical === "use" && "none".startsWith(partial.toLowerCase())
814
+ ? [{ value: join("none"), label: "none", description: "no profile" }]
815
+ : [];
816
+ items = [...profileCompletions(names, join), ...none];
817
+ }
818
+ // create|new|add (new name is free text), list, unknown → items stays null.
819
+
820
+ if (!items || items.length === 0) return null;
821
+ const candidateTokens = items.map((i) => i.label);
822
+ if (suppressExactArgumentMatch(candidateTokens, partial)) return null;
823
+ return items;
824
+ }
package/roles.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import type { ArgumentCompletion } from "./utils.ts";
17
+ import {
18
+ joinArgumentValue,
19
+ suppressExactArgumentMatch,
20
+ } from "./utils.ts";
16
21
 
17
22
  // ── Types ────────────────────────────────────────────────────────────
18
23
 
@@ -430,3 +435,140 @@ export function formatRolesForDisplay(config: RoleConfig): string {
430
435
  );
431
436
  return lines.join("\n");
432
437
  }
438
+
439
+ // ── Argument completions (/role) ─────────────────────────────────────
440
+
441
+ interface RoleSubMeta {
442
+ name: string;
443
+ /** Takes an argument → completion `value` carries a trailing space. */
444
+ takesArg: boolean;
445
+ label: string;
446
+ description: string;
447
+ }
448
+
449
+ const ROLE_SUBS: readonly RoleSubMeta[] = [
450
+ { name: "list", takesArg: false, label: "list", description: "show all roles" },
451
+ {
452
+ name: "show",
453
+ takesArg: false,
454
+ label: "show",
455
+ description: "show all roles (alias of list)",
456
+ },
457
+ {
458
+ name: "config",
459
+ takesArg: true,
460
+ label: "config",
461
+ description: "open the role config screen (picker)",
462
+ },
463
+ {
464
+ name: "set",
465
+ takesArg: true,
466
+ label: "set <role> <provider/model> [thinking]",
467
+ description: "configure one role's model",
468
+ },
469
+ {
470
+ name: "add",
471
+ takesArg: true,
472
+ label: "add <name> [provider/model] [thinking]",
473
+ description: "add a custom role",
474
+ },
475
+ {
476
+ name: "remove",
477
+ takesArg: true,
478
+ label: "remove <name>",
479
+ description: "remove a custom role",
480
+ },
481
+ {
482
+ name: "reset",
483
+ takesArg: false,
484
+ label: "reset",
485
+ description: "reset roles to defaults",
486
+ },
487
+ {
488
+ name: "use",
489
+ takesArg: true,
490
+ label: "use <role|auto>",
491
+ description: "activate one role for the whole workflow",
492
+ },
493
+ ];
494
+
495
+ /**
496
+ * Argument completions for `/role` (pure; unit-tested in roles.test.ts).
497
+ *
498
+ * Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
499
+ * /mcp extension): every returned `value` is the complete replacement for the
500
+ * typed argument text — `set planner opencode-go/muse-…`, never a bare token.
501
+ * Positional rules mirror the /role handler:
502
+ * - `set` arg1 = existing role names, arg2 = model refs (`provider/id`,
503
+ * null when `opts.models` is empty), arg3 = thinking levels;
504
+ * - `use` = role names + `auto`;
505
+ * - `remove` = role names;
506
+ * - `add` arg1 is a NEW name → no completions.
507
+ * Returns null when nothing useful can be offered, including when the typed
508
+ * token is already an exact unique match (exact-match suppression — the
509
+ * dropdown closes so the first Enter submits).
510
+ */
511
+ export function roleArgumentCompletions(
512
+ argumentPrefix: string,
513
+ opts: { roleNames: readonly string[]; models?: readonly string[] },
514
+ ): ArgumentCompletion[] | null {
515
+ const raw = String(argumentPrefix ?? "");
516
+ const join = (token: string) => joinArgumentValue(raw, token);
517
+
518
+ // ── Subcommand position: no whitespace typed yet ────────────────────
519
+ if (!raw.trim() || !/\s/.test(raw)) {
520
+ const lower = raw.trim().toLowerCase();
521
+ const matching = ROLE_SUBS.filter((s) => s.name.startsWith(lower));
522
+ if (matching.length === 0) return null;
523
+ return matching.map((s) => ({
524
+ value: s.takesArg ? `${s.name} ` : s.name,
525
+ label: s.label,
526
+ description: s.description,
527
+ }));
528
+ }
529
+
530
+ // ── Argument position ────────────────────────────────────────────────
531
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
532
+ const cmd = (tokens[0] ?? "").toLowerCase();
533
+ const endsWithWs = /\s$/.test(raw);
534
+ const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
535
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
536
+ const matchToken = (t: string) => t.toLowerCase().startsWith(partial.toLowerCase());
537
+
538
+ const roleNameItems = (): ArgumentCompletion[] =>
539
+ opts.roleNames.flatMap((n) =>
540
+ matchToken(n) ? [{ value: join(n), label: n }] : [],
541
+ );
542
+
543
+ let items: ArgumentCompletion[] | null = null;
544
+
545
+ if (cmd === "set") {
546
+ if (committedCount <= 1) {
547
+ items = roleNameItems();
548
+ } else if (committedCount === 2) {
549
+ items = (opts.models ?? []).flatMap((m) =>
550
+ matchToken(m) ? [{ value: join(m), label: m }] : [],
551
+ );
552
+ } else {
553
+ items = VALID_THINKING_LEVELS.flatMap((t) =>
554
+ matchToken(t) ? [{ value: join(t), label: t }] : [],
555
+ );
556
+ }
557
+ } else if (cmd === "use") {
558
+ items = [
559
+ ...roleNameItems(),
560
+ ...("auto".startsWith(partial.toLowerCase())
561
+ ? [{ value: join("auto"), label: "auto" }]
562
+ : []),
563
+ ];
564
+ } else if (cmd === "remove") {
565
+ items = roleNameItems();
566
+ }
567
+ // add (new name is free text), config, list/show, reset, unknown → null.
568
+
569
+ if (!items || items.length === 0) return null;
570
+ if (suppressExactArgumentMatch(items.map((i) => i.label), partial)) {
571
+ return null;
572
+ }
573
+ return items;
574
+ }
package/utils.ts CHANGED
@@ -1583,3 +1583,63 @@ export function renderReviewContextBlock(
1583
1583
  }
1584
1584
  return out;
1585
1585
  }
1586
+
1587
+ // ── Slash-command argument completions (pi-tui contract) ─────────────
1588
+ //
1589
+ // `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
1590
+ // passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
1591
+ // text after the command name and, on accept, `applyCompletion` replaces
1592
+ // that whole argument text with `item.value` (the slash-argument branch:
1593
+ // `newLine = beforePrefix + item.value`). So an argument-position item's
1594
+ // `value` must be the complete replacement — e.g. `"use <name>"`, never the
1595
+ // bare token. Pi's own /mcp extension follows this contract
1596
+ // (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
1597
+ // that enforces it: it keeps every committed token and appends (or replaces
1598
+ // the last partial token with) `token`.
1599
+
1600
+ /** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
1601
+ export interface ArgumentCompletion {
1602
+ value: string;
1603
+ label: string;
1604
+ description?: string;
1605
+ }
1606
+
1607
+ /**
1608
+ * Build the full-argument replacement `value` for a completion token.
1609
+ *
1610
+ * `prefix` is the typed argument text so far. Tokens separated by whitespace
1611
+ * are "committed"; if `prefix` ends with whitespace the user is starting a
1612
+ * fresh token, so `token` is appended — otherwise it replaces the trailing
1613
+ * partial token. Pure; never throws.
1614
+ *
1615
+ * joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
1616
+ * → "use Syn-Deepseek-v4.1-Flash-Planner"
1617
+ * joinArgumentValue("use ", "none") → "use none"
1618
+ * joinArgumentValue("", "list") → "list"
1619
+ */
1620
+ export function joinArgumentValue(prefix: string, token: string): string {
1621
+ const raw = String(prefix ?? "");
1622
+ const endsWithWs = /\s$/.test(raw);
1623
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
1624
+ const committed = endsWithWs ? parts : parts.slice(0, -1);
1625
+ return [...committed, token].join(" ");
1626
+ }
1627
+
1628
+ /**
1629
+ * Exact-match suppression for slash-command argument completions: when the
1630
+ * token being typed (`partial`, "" while the prefix ends with whitespace)
1631
+ * already equals the ONLY remaining candidate, return true so the completer
1632
+ * yields null — pi's editor then closes the dropdown and the FIRST Enter
1633
+ * submits instead of being consumed by the completion-accept handler
1634
+ * (editor.js `tui.select.confirm`). Pure.
1635
+ */
1636
+ export function suppressExactArgumentMatch(
1637
+ candidates: readonly string[],
1638
+ partial: string,
1639
+ ): boolean {
1640
+ if (!partial) return false;
1641
+ return (
1642
+ candidates.length === 1 &&
1643
+ candidates[0].toLowerCase() === partial.toLowerCase()
1644
+ );
1645
+ }