@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 +15 -3
- package/commit-policy.ts +273 -0
- package/index.ts +167 -147
- package/package.json +1 -1
- package/profiles.ts +208 -1
- package/roles.ts +142 -0
- package/utils.ts +60 -0
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
|
|
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
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
//
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
(pi as any).
|
|
2543
|
-
|
|
2544
|
-
|
|
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(
|
|
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
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
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 `
|
|
6472
|
-
*
|
|
6473
|
-
*
|
|
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
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
6890
|
-
//
|
|
6891
|
-
|
|
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
|
-
//
|
|
7071
|
-
//
|
|
7072
|
-
//
|
|
7073
|
-
|
|
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
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
|
+
}
|