@dieulc/workflow 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -82,11 +82,21 @@ import {
82
82
  import {
83
83
  WORKFLOW_ROLE_ENV,
84
84
  explainCommandRefusal,
85
+ gitCommitPolicyHit,
85
86
  policyForRole,
86
87
  resolveSessionRole,
87
88
  subagentEnv,
88
89
  type WorkflowRole,
89
90
  } from "./permissions.ts";
91
+ import {
92
+ commitConfigPaths,
93
+ describeCommitPolicyMode,
94
+ readCommitPolicyConfig,
95
+ stepCommitDecision,
96
+ writeCommitPolicyConfig,
97
+ type CommitPolicyMode,
98
+ type StepCommitCandidate,
99
+ } from "./commit-policy.ts";
90
100
  import {
91
101
  isGitRepo,
92
102
  isGitRepoSync,
@@ -115,6 +125,24 @@ import {
115
125
  type RoleModel,
116
126
  type ThinkingLevel,
117
127
  } from "./roles.ts";
128
+ import {
129
+ defaultProfileConfig,
130
+ loadProfilesDetailed,
131
+ profilesFingerprint,
132
+ saveProfiles,
133
+ getProfile,
134
+ formatProfilesForDisplay,
135
+ describeProfile,
136
+ validateProfileName,
137
+ applyProfileOverlay,
138
+ missingProfileRoles,
139
+ readDefaultProfileSetting,
140
+ writeDefaultProfileSetting,
141
+ defaultProfileSettingPaths,
142
+ PROFILE_ROLES,
143
+ type ProfileConfig,
144
+ type Profile,
145
+ } from "./profiles.ts";
118
146
  import {
119
147
  applySubmittedPlan,
120
148
  formatReviewStatusLines,
@@ -372,6 +400,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
372
400
  let todoItems: TodoItem[] = [];
373
401
  // Plan file the current todo list was derived from (drives merge vs replace).
374
402
  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;
408
+ // ── Deterministic per-step commits (allow mode) ──
409
+ // In 'allow' mode the EXTENSION — not the model — owns commit timing:
410
+ // exactly one commit per batch of newly completed plan steps, made at the
411
+ // workflow_todo tool_result boundary (the runner awaits tool_result
412
+ // handlers, so the commit lands before the model starts the next step).
413
+ // committedStepRefs makes the guarantee idempotent; stepCommitsSeeded
414
+ // marks pre-existing done steps (session resume, re-executed plan) as
415
+ // already accounted for so they never trigger a spurious commit.
416
+ const committedStepRefs = new Set<string>();
417
+ let stepCommitsSeeded = false;
375
418
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
376
419
  let turnsSinceLastTodoWrite = 0;
377
420
  let turnsSinceLastReminder = 0;
@@ -448,6 +491,232 @@ export default function workflowExtension(pi: ExtensionAPI) {
448
491
  void _e;
449
492
  }
450
493
  }
494
+
495
+ // ── Model profiles state (session-scoped activation) ───────────
496
+ let profilesConfig: ProfileConfig = defaultProfileConfig();
497
+ let profilesLoadedFor: string | undefined;
498
+ let profilesLoadedFingerprint: string | undefined;
499
+ /** Reason the last load skipped the user file — only corrupt/legacy warns. */
500
+ let profilesFallbackReason: string | undefined;
501
+ let profilesFallbackSeverity: "info" | "warning" | undefined;
502
+ let profilesFallbackNotified = false;
503
+ /** Active profile for THIS session — never global (concurrent sessions each pick their own). */
504
+ let activeProfileName: string | null = null;
505
+ /** True once the user picked explicitly in this session; otherwise workflow.defaultProfile applies. */
506
+ let profileExplicit = false;
507
+ /** Cache for the effective (profile-overlaid) roles — see effectiveRoles(). */
508
+ let effectiveRolesCache: {
509
+ base: RoleConfig;
510
+ profile: string | null;
511
+ roles: Role[];
512
+ } | null = null;
513
+
514
+ function invalidateEffectiveRoles(): void {
515
+ effectiveRolesCache = null;
516
+ }
517
+
518
+ /**
519
+ * The single profiles accessor, mirroring ensureRoleConfig: reloads when the
520
+ * working directory changes or either profiles.json changes on disk (mtime/
521
+ * size), so a `/profile` edit made in another window is picked up without a
522
+ * `/reload`. Non-destructive: a bad or missing file is never overwritten
523
+ * (see loadProfilesDetailed). A profile activated in this session that has
524
+ * vanished from disk is deactivated here (with the fallback warning).
525
+ */
526
+ function ensureProfilesConfig(cwd?: string): ProfileConfig {
527
+ const key = cwd ?? "";
528
+ const fingerprint = profilesFingerprint(key);
529
+ if (
530
+ profilesLoadedFor === key &&
531
+ profilesLoadedFingerprint === fingerprint
532
+ ) {
533
+ return profilesConfig;
534
+ }
535
+ const loaded = loadProfilesDetailed(key);
536
+ profilesConfig = loaded.config;
537
+ profilesLoadedFor = key;
538
+ profilesLoadedFingerprint = fingerprint;
539
+ profilesFallbackReason = loaded.usedDefaults ? loaded.reason : undefined;
540
+ profilesFallbackSeverity = loaded.usedDefaults
541
+ ? loaded.severity
542
+ : undefined;
543
+ profilesFallbackNotified = false;
544
+ invalidateEffectiveRoles();
545
+ // A profile selected in this session may have been deleted/renamed in
546
+ // another window — fall back to the base roles flow.
547
+ if (activeProfileName && !getProfile(profilesConfig, activeProfileName)) {
548
+ activeProfileName = null;
549
+ }
550
+ return profilesConfig;
551
+ }
552
+
553
+ /**
554
+ * Surface a profiles load problem once per load. A MISSING file is the
555
+ * normal "feature unused" state (info — never notified); only corrupt or
556
+ * incompatible files warn.
557
+ */
558
+ function notifyProfilesFallback(ctx?: ExtensionContext): void {
559
+ if (!profilesFallbackReason || profilesFallbackNotified) return;
560
+ profilesFallbackNotified = true;
561
+ if (profilesFallbackSeverity !== "warning") return;
562
+ try {
563
+ ctx?.ui.notify(`Profiles: ${profilesFallbackReason}`, "warning");
564
+ } catch (_e) {
565
+ void _e;
566
+ }
567
+ }
568
+
569
+ /** Resolve the session's active profile object (null when none/unknown). */
570
+ function resolveActiveProfile(): Profile | null {
571
+ if (!activeProfileName) return null;
572
+ return getProfile(profilesConfig, activeProfileName) ?? null;
573
+ }
574
+
575
+ /**
576
+ * The effective roles for THIS session: the file-backed base roles with the
577
+ * active profile's model overrides applied (see applyProfileOverlay). All
578
+ * model-RESOLUTION sites read through this; all EDITING sites (/role, role
579
+ * picker) keep using the base `roleConfig`. With no profile active the
580
+ * overlay is a shallow copy — behaviour is identical to the roles.json flow.
581
+ * Cache is keyed by the base config object identity (ensureRoleConfig
582
+ * reassigns it on file changes) + the active profile name, and invalidated
583
+ * by ensureProfilesConfig / setActiveProfile.
584
+ */
585
+ function effectiveRoles(): Role[] {
586
+ const base = roleConfig;
587
+ const profileName = activeProfileName;
588
+ const cached = effectiveRolesCache;
589
+ if (cached && cached.base === base && cached.profile === profileName) {
590
+ return cached.roles;
591
+ }
592
+ const roles = applyProfileOverlay(base.roles, resolveActiveProfile());
593
+ effectiveRolesCache = { base, profile: profileName, roles };
594
+ return roles;
595
+ }
596
+
597
+ /** getRole over the effective (profile-overlaid) roles. */
598
+ function getEffectiveRole(name: string): Role | undefined {
599
+ return getRole({ ...roleConfig, roles: effectiveRoles() }, name);
600
+ }
601
+
602
+ /** Footer label for the active profile ("" when none). */
603
+ function activeProfileLabel(): string {
604
+ return activeProfileName ? `⚡ ${activeProfileName}` : "";
605
+ }
606
+
607
+ /**
608
+ * /role display: the base roles (what /role edits) plus, when a profile is
609
+ * active, an explicit section naming the profile and its model overrides.
610
+ * Without a profile this is exactly the plain roles display.
611
+ */
612
+ function formatRolesDisplayWithProfile(): string {
613
+ const base = formatRolesForDisplay(roleConfig);
614
+ const profile = resolveActiveProfile();
615
+ if (!profile) return base;
616
+ const lines: string[] = [
617
+ base,
618
+ "",
619
+ `Active profile: ⚡ ${profile.name}${
620
+ profile.description ? ` — ${profile.description}` : ""
621
+ }`,
622
+ ];
623
+ for (const roleName of PROFILE_ROLES) {
624
+ const m = profile.roles[roleName];
625
+ if (m)
626
+ lines.push(
627
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking}) (profile override)`,
628
+ );
629
+ }
630
+ lines.push(
631
+ ` /profile edit ${profile.name} to change the profile • /profile use none to deactivate`,
632
+ );
633
+ return lines.join("\n");
634
+ }
635
+
636
+ /** Multi-line notification describing what an activation resolves to. */
637
+ function profileActivationSummary(name: string): string {
638
+ const profile = getProfile(profilesConfig, name);
639
+ if (!profile) return `Profile "${name}" activated.`;
640
+ const lines = [`Profile ⚡ ${profile.name} activated:`];
641
+ for (const roleName of PROFILE_ROLES) {
642
+ const m = profile.roles[roleName];
643
+ if (m)
644
+ lines.push(
645
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking})`,
646
+ );
647
+ }
648
+ const missing = missingProfileRoles(profile);
649
+ if (missing.length) {
650
+ lines.push(
651
+ ` ${missing.join(", ")}: base role models (not overridden by this profile)`,
652
+ );
653
+ }
654
+ if (workflowMode) {
655
+ lines.push(` Applied to ${workflowMode} mode now; explore/review pick it up on next use.`);
656
+ } else {
657
+ lines.push(
658
+ " Applies on the next /plan, /build, explore subagent, or review round.",
659
+ );
660
+ }
661
+ return lines.join("\n");
662
+ }
663
+
664
+ /**
665
+ * Activate/deactivate the session's profile. Session-scoped by design — the
666
+ * state rides the `workflow` session entry (persistState), never a global
667
+ * file, so two concurrent sessions can run different profiles. When the
668
+ * session is in plan/build mode the role model is re-applied immediately so
669
+ * the profile's planner/builder take effect right away; explorer/reviewer
670
+ * resolution picks the profile up on next subagent/review round.
671
+ */
672
+ async function setActiveProfile(
673
+ ctx: ExtensionContext,
674
+ name: string | null,
675
+ opts?: { explicit?: boolean; quiet?: boolean },
676
+ ): Promise<void> {
677
+ ensureProfilesConfig((ctx as any)?.cwd);
678
+ const trimmed = name ? String(name).trim() : null;
679
+ if (trimmed) {
680
+ const profile = getProfile(profilesConfig, trimmed);
681
+ if (!profile) {
682
+ ctx.ui.notify(
683
+ `Unknown profile "${trimmed}". Available: ${
684
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
685
+ "(none — /profile create <name>)"
686
+ }`,
687
+ "warning",
688
+ );
689
+ return;
690
+ }
691
+ activeProfileName = profile.name;
692
+ } else {
693
+ activeProfileName = null;
694
+ }
695
+ profileExplicit = opts?.explicit ?? true;
696
+ invalidateEffectiveRoles();
697
+ try {
698
+ persistState();
699
+ } catch (_e) {
700
+ void _e;
701
+ }
702
+ updateStatus(ctx as any);
703
+ if (workflowMode) {
704
+ try {
705
+ await applyModeModel(ctx as any, { quiet: opts?.quiet });
706
+ } catch (_e) {
707
+ void _e;
708
+ }
709
+ }
710
+ if (!opts?.quiet) {
711
+ ctx.ui.notify(
712
+ activeProfileName
713
+ ? profileActivationSummary(activeProfileName)
714
+ : "Profile deactivated — default roles flow active.",
715
+ "info",
716
+ );
717
+ }
718
+ }
719
+
451
720
  function activeRoleLabel(): string {
452
721
  if (roleConfig.activeRole)
453
722
  return `${roleIcon(roleConfig.activeRole)} ${roleConfig.activeRole}`;
@@ -598,16 +867,32 @@ export default function workflowExtension(pi: ExtensionAPI) {
598
867
  }
599
868
 
600
869
  function updateStatus(ctx: ExtensionContext) {
870
+ // Profile prefix (⚡ name) when one is active, then the role label.
871
+ const profileLabel = activeProfileLabel();
601
872
  const roleLabel = activeRoleLabel();
602
- const suffix = roleLabel ? ` | ${roleLabel}` : "";
873
+ const statusParts = [profileLabel, roleLabel].filter(Boolean);
874
+ const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
875
+ // Commit-policy surfacing (workflow.commitPolicy): always visible so the
876
+ // user can see at a glance what the agent may do to git history. Read
877
+ // live so /commit-policy changes show up without a reload.
878
+ let commitMode: CommitPolicyMode = "never";
879
+ try {
880
+ commitMode = readCommitPolicyConfig(
881
+ (ctx as any).cwd ?? process.cwd(),
882
+ getAgentDir(),
883
+ ).config.mode;
884
+ } catch {
885
+ commitMode = "never";
886
+ }
887
+ const policy = ` · commits:${commitMode}`;
603
888
  const staleMarker = todoStale ? " ⚠" : "";
604
889
  const done = todoItems.filter((t) => isDone(t)).length;
605
890
  const total = todoItems.length;
606
891
  if (workflowMode === "build") {
607
892
  const label =
608
893
  total > 0
609
- ? `▶ build ${done}/${total}${staleMarker}${suffix}`
610
- : `▶ build${suffix}`;
894
+ ? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
895
+ : `▶ build${suffix}${policy}`;
611
896
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
612
897
  } else if (workflowMode === "plan") {
613
898
  const base = plannotatorActive
@@ -615,16 +900,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
615
900
  : "⏸ plan";
616
901
  const label =
617
902
  total > 0
618
- ? `${base} ${done}/${total}${staleMarker}${suffix}`
619
- : `${base}${suffix}`;
903
+ ? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
904
+ : `${base}${suffix}${policy}`;
620
905
  ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
621
906
  } else if (total > 0) {
622
907
  ctx.ui.setStatus(
623
908
  "workflow",
624
- ctx.ui.theme.fg("muted", `◦ ${done}/${total}${staleMarker}${suffix}`),
909
+ ctx.ui.theme.fg(
910
+ "muted",
911
+ `◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
912
+ ),
625
913
  );
626
914
  } else {
627
- ctx.ui.setStatus("workflow", undefined);
915
+ ctx.ui.setStatus(
916
+ "workflow",
917
+ ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
918
+ );
628
919
  }
629
920
  if (todoItems.length > 0) {
630
921
  ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
@@ -675,7 +966,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
675
966
  /** Reviewer model label for the mode band (best-effort). */
676
967
  function reviewModelLabel(): string {
677
968
  ensureRoleConfig(reviewCwd());
678
- const r = getRole(roleConfig, REVIEW_ROLE_NAME);
969
+ ensureProfilesConfig(reviewCwd());
970
+ const r = getEffectiveRole(REVIEW_ROLE_NAME);
679
971
  return r?.model ? formatModelRef(r.model) : "unconfigured";
680
972
  }
681
973
 
@@ -843,9 +1135,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
843
1135
  ): Promise<string> {
844
1136
  return (async () => {
845
1137
  const cwd = reviewCwd();
1138
+ ensureRoleConfig(cwd);
1139
+ ensureProfilesConfig(cwd);
846
1140
  const agents = discoverAgents(cwd);
847
1141
  const base = agents.find((a) => a.name === "scout") ?? agents[0];
848
- const explorerRole = getRole(roleConfig, "explorer");
1142
+ const explorerRole = getEffectiveRole("explorer");
849
1143
  const m = explorerRole?.model;
850
1144
  const agent: AgentConfig = {
851
1145
  name: base?.name ?? "review-scout",
@@ -883,11 +1177,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
883
1177
  config: () => reviewConfigNow()?.config,
884
1178
  reviewerModel: () => {
885
1179
  ensureRoleConfig(cwd);
886
- const reviewer = getRole(roleConfig, REVIEW_ROLE_NAME);
1180
+ ensureProfilesConfig(cwd);
1181
+ const reviewer = getEffectiveRole(REVIEW_ROLE_NAME);
887
1182
  if (reviewer?.model) return reviewer.model;
888
1183
  // Graceful degradation: fall back to the planner model rather than
889
1184
  // silently running no review at all.
890
- const planner = getRole(roleConfig, "planner");
1185
+ const planner = getEffectiveRole("planner");
891
1186
  return planner?.model;
892
1187
  },
893
1188
  findModel: (provider: string, id: string) =>
@@ -1087,7 +1382,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
1087
1382
  if (!sub || sub === "status") {
1088
1383
  const resolved = readReviewModeConfig(cwd, agentDir);
1089
1384
  const c = resolved.config;
1090
- const model = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1385
+ ensureProfilesConfig(cwd);
1386
+ const model = getEffectiveRole(REVIEW_ROLE_NAME)?.model;
1091
1387
  ctx.ui.notify(
1092
1388
  formatReviewStatusLines({
1093
1389
  enabled: c.enabled,
@@ -1200,6 +1496,112 @@ export default function workflowExtension(pi: ExtensionAPI) {
1200
1496
  },
1201
1497
  });
1202
1498
 
1499
+ // ── /commit-policy: view/set the commit policy (workflow.commitPolicy) ──
1500
+ // Available in every mode: it only writes settings JSON; the gate itself
1501
+ // lives in tool_call and plan mode keeps its own stricter rules.
1502
+ pi.registerCommand("commit-policy", {
1503
+ description:
1504
+ "View or set the commit policy (what the agent may git add/commit/push): /commit-policy never|ask|allow [global|project]",
1505
+ handler: async (args, ctx) => {
1506
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
1507
+ const agentDir = getAgentDir();
1508
+ const tokens = String(args || "")
1509
+ .trim()
1510
+ .split(/\s+/)
1511
+ .filter(Boolean);
1512
+ const sub = (tokens[0] || "").toLowerCase();
1513
+ const scope: "global" | "project" =
1514
+ tokens.includes("project") && !tokens.includes("global")
1515
+ ? "project"
1516
+ : "global";
1517
+
1518
+ if (!sub || sub === "status") {
1519
+ const resolved = readCommitPolicyConfig(cwd, agentDir);
1520
+ const paths = commitConfigPaths(cwd, agentDir);
1521
+ const where =
1522
+ resolved.sources.mode === "default"
1523
+ ? "default — no settings file supplies it"
1524
+ : `${resolved.sources.mode} settings`;
1525
+ ctx.ui.notify(
1526
+ [
1527
+ `Commit policy: ${resolved.config.mode} (${where})`,
1528
+ ` ${describeCommitPolicyMode(resolved.config.mode)}`,
1529
+ `Global settings: ${paths.global}`,
1530
+ `Project settings: ${paths.project}`,
1531
+ "Usage: /commit-policy never|ask|allow [global|project]",
1532
+ ].join("\n"),
1533
+ "info",
1534
+ );
1535
+ updateStatus(ctx as any);
1536
+ return;
1537
+ }
1538
+
1539
+ if (sub !== "never" && sub !== "ask" && sub !== "allow") {
1540
+ ctx.ui.notify(
1541
+ [
1542
+ "Usage: /commit-policy never|ask|allow [global|project]",
1543
+ " never agent cannot git add/commit/push — you commit (or /commit)",
1544
+ " ask agent may commit; each git commit/push asks first",
1545
+ " allow agent may commit freely (per-step during plan execution)",
1546
+ ].join("\n"),
1547
+ "info",
1548
+ );
1549
+ return;
1550
+ }
1551
+
1552
+ const res = writeCommitPolicyConfig(scope, cwd, agentDir, {
1553
+ mode: sub as CommitPolicyMode,
1554
+ });
1555
+ if (!res.ok) {
1556
+ ctx.ui.notify(
1557
+ `Could not write commit-policy settings: ${res.error ?? "unknown error"}`,
1558
+ "error",
1559
+ );
1560
+ return;
1561
+ }
1562
+ ctx.ui.notify(
1563
+ `Commit policy → ${sub} (${scope})\n${describeCommitPolicyMode(
1564
+ sub as CommitPolicyMode,
1565
+ )}\nSettings: ${res.path}`,
1566
+ "info",
1567
+ );
1568
+ updateStatus(ctx as any);
1569
+ },
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
+ },
1603
+ });
1604
+
1203
1605
  pi.registerCommand("review-prompt", {
1204
1606
  description:
1205
1607
  "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
@@ -1357,13 +1759,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
1357
1759
  if (!workflowMode) return false;
1358
1760
  try {
1359
1761
  ensureRoleConfig((ctx as any)?.cwd ?? ctx.cwd);
1762
+ ensureProfilesConfig((ctx as any)?.cwd ?? ctx.cwd);
1360
1763
  } catch (_e) {
1361
1764
  void _e;
1362
1765
  }
1363
- const resolved = resolveModeModel(roleConfig, workflowMode);
1766
+ // Resolution reads the EFFECTIVE roles (base roles + active profile
1767
+ // overlay); with no profile active this is behaviourally identical to
1768
+ // the base config.
1769
+ const effectiveCfg: RoleConfig = { ...roleConfig, roles: effectiveRoles() };
1770
+ const resolved = resolveModeModel(effectiveCfg, workflowMode);
1364
1771
  if (!resolved) {
1365
- const targetName = roleConfig.activeRole ?? MODE_ROLE_MAP[workflowMode];
1366
- const role = targetName ? getRole(roleConfig, targetName) : undefined;
1772
+ const targetName = effectiveCfg.activeRole ?? MODE_ROLE_MAP[workflowMode];
1773
+ const role = targetName ? getRole(effectiveCfg, targetName) : undefined;
1367
1774
  if (targetName && !role) {
1368
1775
  ctx.ui.notify(
1369
1776
  `Workflow ${workflowMode}: role "${targetName}" not found — staying on current model.`,
@@ -1549,6 +1956,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
1549
1956
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
1550
1957
  role: {
1551
1958
  activeRole: roleConfig.activeRole,
1959
+ // Session-scoped profile activation (see setActiveProfile): explicit
1960
+ // picks restore exactly; non-explicit entries re-derive the default.
1961
+ activeProfile: profileExplicit ? activeProfileName : null,
1962
+ profileExplicit,
1552
1963
  },
1553
1964
  defaultModel,
1554
1965
  lastModeSwitch,
@@ -1845,7 +2256,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1845
2256
  };
1846
2257
  const firstTodo = todoItems[0];
1847
2258
  const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
1848
- const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
2259
+ const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
1849
2260
  (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
1850
2261
  (pi as any).sendMessage?.(
1851
2262
  {
@@ -2121,6 +2532,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
2121
2532
 
2122
2533
  // Inject a user message → triggers an agent turn that proposes + confirms + commits
2123
2534
  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;
2124
2539
  if ((pi as any).sendUserMessage) {
2125
2540
  (pi as any).sendUserMessage(message);
2126
2541
  } else {
@@ -2628,19 +3043,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
2628
3043
  await openRolePicker(ctx as any);
2629
3044
  return;
2630
3045
  }
2631
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3046
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2632
3047
  return;
2633
3048
  }
2634
3049
 
2635
3050
  if (sub === "list" || sub === "show") {
2636
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3051
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2637
3052
  return;
2638
3053
  }
2639
3054
 
2640
3055
  if (sub === "config") {
2641
3056
  if (!(ctx as any)?.hasUI) {
2642
3057
  ctx.ui.notify(
2643
- formatRolesForDisplay(roleConfig) +
3058
+ formatRolesDisplayWithProfile() +
2644
3059
  "\n\nPicker needs UI — use /role set instead.",
2645
3060
  "info",
2646
3061
  );
@@ -2820,125 +3235,1498 @@ export default function workflowExtension(pi: ExtensionAPI) {
2820
3235
  },
2821
3236
  });
2822
3237
 
2823
- // ── Role config screen (tabbed picker: one block per role) ─────────
3238
+ // ── Model profiles command + management UI ───────────────────────
2824
3239
  //
2825
- // One overlay with a tab per role (built-ins first, then customs) plus
2826
- // a Submit tab. Each role tab has two phases: pick model (from the
2827
- // connected providers — scoped models first, Ctrl+O for all) then pick
2828
- // thinking level. Selections stage locally; Submit saves to roles.json.
2829
-
2830
- interface StagedRoleModel {
2831
- provider: string;
2832
- id: string;
2833
- thinking: ThinkingLevel;
2834
- }
2835
-
2836
- interface RoleCatalogEntry {
2837
- provider: string;
2838
- id: string;
2839
- inScope: boolean;
2840
- }
2841
-
2842
- function collectRoleCatalog(ctx: ExtensionContext): {
2843
- scoped: RoleCatalogEntry[];
2844
- all: RoleCatalogEntry[];
2845
- truncated: boolean;
2846
- } {
2847
- const scoped: RoleCatalogEntry[] = [];
2848
- try {
2849
- const raw = ((ctx as any)?.scopedModels ?? []) as any[];
2850
- for (const s of raw) {
2851
- const m = s?.model ?? s;
2852
- if (m && typeof m.provider === "string" && typeof m.id === "string")
2853
- scoped.push({ provider: m.provider, id: m.id, inScope: true });
2854
- }
2855
- } catch (_e) {
2856
- void _e;
2857
- }
2858
- let available: any[] = [];
3240
+ // A profile is a named combination of role model settings (planner /
3241
+ // explorer / builder / reviewer). Profiles are DEFINED in profiles.json;
3242
+ // ACTIVATION is session-scoped (setActiveProfile) so two concurrent
3243
+ // sessions can run different profiles. /role keeps editing the base roles;
3244
+ // an active profile only overlays models on top (see effectiveRoles()).
3245
+
3246
+ const PROFILE_USAGE =
3247
+ "Usage: /profile (picker) | /profile list | /profile show <name> | " +
3248
+ "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
3249
+ "/profile rename <old> <new> | /profile delete <name> | " +
3250
+ "/profile default [name|none] [global|project]";
3251
+
3252
+ /** Effective workflow.defaultProfile for display (never throws). */
3253
+ function currentDefaultProfileName(cwd?: string): string | undefined {
2859
3254
  try {
2860
- const reg = (ctx as any)?.modelRegistry;
2861
- if (reg && typeof reg.getAvailable === "function")
2862
- available = [...reg.getAvailable()];
2863
- else if (reg && typeof reg.getAll === "function")
2864
- available = [...reg.getAll()];
3255
+ return readDefaultProfileSetting(cwd ?? process.cwd(), getAgentDir())
3256
+ .value;
2865
3257
  } catch (_e) {
2866
3258
  void _e;
3259
+ return undefined;
2867
3260
  }
2868
- const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
2869
- const rest: RoleCatalogEntry[] = [];
2870
- for (const m of available) {
2871
- if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
2872
- continue;
2873
- const key = `${m.provider}/${m.id}`;
2874
- if (seen.has(key)) continue;
2875
- seen.add(key);
2876
- rest.push({ provider: m.provider, id: m.id, inScope: false });
2877
- }
2878
- const CAP = 200;
2879
- const all = [...scoped, ...rest];
2880
- return {
2881
- scoped,
2882
- all: all.slice(0, CAP),
2883
- truncated: all.length > CAP,
2884
- };
2885
3261
  }
2886
3262
 
2887
- function orderedRoleTabs(): Role[] {
2888
- // Built-in roles first in their declared order, then custom roles.
2889
- // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
2890
- const rank = (name: string) => {
2891
- const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
2892
- name.toLowerCase(),
2893
- );
2894
- return i === -1 ? BUILT_IN_ROLES.length : i;
2895
- };
2896
- return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
3263
+ /** Seed a new profile from the CURRENT effective roles (base + active profile). */
3264
+ function buildProfileSeed(name: string, description = ""): Profile {
3265
+ const seed: Profile = { name, description, roles: {} };
3266
+ for (const roleName of PROFILE_ROLES) {
3267
+ const m = getEffectiveRole(roleName)?.model;
3268
+ if (m) seed.roles[roleName] = { ...m };
3269
+ }
3270
+ return seed;
2897
3271
  }
2898
3272
 
2899
- async function openRolePicker(ctx: ExtensionContext): Promise<void> {
2900
- const tabs = orderedRoleTabs();
2901
- if (tabs.length === 0) {
2902
- ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
2903
- return;
2904
- }
2905
- const catalog = collectRoleCatalog(ctx);
2906
- if (catalog.all.length === 0) {
2907
- ctx.ui.notify(
2908
- "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
2909
- "warning",
2910
- );
2911
- return;
2912
- }
2913
- // Stage current values so the picker opens on the active selection.
2914
- const staged = new Map<string, StagedRoleModel>();
2915
- for (const r of tabs) staged.set(r.name, { ...r.model });
3273
+ const listThemeOf = (theme: any) => ({
3274
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3275
+ selectedText: (t: string) => theme.fg("accent", t),
3276
+ description: (t: string) => theme.fg("muted", t),
3277
+ scrollInfo: (t: string) => theme.fg("dim", t),
3278
+ noMatch: (t: string) => theme.fg("warning", t),
3279
+ });
2916
3280
 
2917
- overlayActive = true;
2918
- let saved: Map<string, StagedRoleModel> | null = null;
2919
- try {
2920
- saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
2921
- (tui, theme, _kb, done) => {
2922
- let currentTab = 0; // tabs.length = Submit
2923
- let phase: "model" | "thinking" = "model";
2924
- let showAll = catalog.scoped.length === 0;
2925
- let cached: string[] | undefined;
2926
- // One SelectList per role tab for models + one shared for thinking,
2927
- // so filter text + cursor survive tab switches.
2928
- const listTheme = {
2929
- selectedPrefix: (t: string) => theme.fg("accent", t),
2930
- selectedText: (t: string) => theme.fg("accent", t),
2931
- description: (t: string) => theme.fg("muted", t),
2932
- scrollInfo: (t: string) => theme.fg("dim", t),
2933
- noMatch: (t: string) => theme.fg("warning", t),
2934
- };
2935
- const currentRef = (role: Role) =>
2936
- `${role.model.provider}/${role.model.id}`;
2937
- const buildModelItems = (role: Role): SelectItem[] => {
2938
- const st = staged.get(role.name)!;
2939
- const cur = `${st.provider}/${st.id}`;
2940
- const src = showAll ? catalog.all : catalog.scoped;
2941
- return src.map((e) => {
3281
+ /** Small confirm overlay (used by /profile delete). True only on explicit Delete. */
3282
+ async function confirmProfileDelete(
3283
+ ctx: ExtensionContext,
3284
+ profile: Profile,
3285
+ ): Promise<boolean> {
3286
+ const result = await ctx.ui.custom<boolean | null>(
3287
+ (tui, theme, _kb, done) => {
3288
+ const list = new SelectList(
3289
+ [
3290
+ {
3291
+ value: "delete",
3292
+ label: `Delete ⚡ ${profile.name}`,
3293
+ description: "remove from profiles.json (cannot be undone)",
3294
+ },
3295
+ {
3296
+ value: "cancel",
3297
+ label: "Cancel",
3298
+ description: "keep the profile",
3299
+ },
3300
+ ],
3301
+ 2,
3302
+ listThemeOf(theme),
3303
+ );
3304
+ let cached: string[] | undefined;
3305
+ return {
3306
+ render: (width: number) => {
3307
+ if (cached) return cached;
3308
+ const W = Math.max(1, width);
3309
+ const lines: string[] = [];
3310
+ lines.push(theme.fg("accent", "─".repeat(W)));
3311
+ lines.push(
3312
+ ` ${theme.fg("warning", `Delete profile "${profile.name}"?`)}`,
3313
+ );
3314
+ lines.push("");
3315
+ for (const l of list.render(Math.max(1, W - 2))) lines.push(` ${l}`);
3316
+ lines.push("");
3317
+ lines.push(
3318
+ ` ${theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel")}`,
3319
+ );
3320
+ lines.push(theme.fg("accent", "─".repeat(W)));
3321
+ cached = lines;
3322
+ return lines;
3323
+ },
3324
+ invalidate: () => {
3325
+ cached = undefined;
3326
+ },
3327
+ handleInput: (data: string) => {
3328
+ if (matchesKey(data, Key.escape)) {
3329
+ done(null);
3330
+ return;
3331
+ }
3332
+ if (matchesKey(data, Key.enter)) {
3333
+ done(list.getSelectedItem()?.value === "delete");
3334
+ return;
3335
+ }
3336
+ list.handleInput(data);
3337
+ cached = undefined;
3338
+ tui.requestRender();
3339
+ },
3340
+ };
3341
+ },
3342
+ );
3343
+ return result === true;
3344
+ }
3345
+
3346
+ /**
3347
+ * Free-text profile name entry (Editor overlay, same input pattern as the
3348
+ * questionnaire). Resolves the validated name, or null on cancel/invalid-Esc.
3349
+ */
3350
+ async function inputProfileName(
3351
+ ctx: ExtensionContext,
3352
+ opts: {
3353
+ title: string;
3354
+ initial?: string;
3355
+ /** Names that would collide (case-insensitive), minus any being renamed. */
3356
+ existing?: readonly string[];
3357
+ },
3358
+ ): Promise<string | null> {
3359
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
3360
+ const editorTheme: EditorTheme = {
3361
+ borderColor: (s) => theme.fg("accent", s),
3362
+ selectList: {
3363
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3364
+ selectedText: (t: string) => theme.fg("accent", t),
3365
+ description: (t: string) => theme.fg("muted", t),
3366
+ scrollInfo: (t: string) => theme.fg("dim", t),
3367
+ noMatch: (t: string) => theme.fg("warning", t),
3368
+ },
3369
+ };
3370
+ const editor = new Editor(tui, editorTheme);
3371
+ if (opts.initial) editor.setText(opts.initial);
3372
+ let error = "";
3373
+ let cached: string[] | undefined;
3374
+ editor.onSubmit = (value: string) => {
3375
+ const check = validateProfileName(String(value ?? ""), opts.existing);
3376
+ if (!check.ok) {
3377
+ error = check.error;
3378
+ cached = undefined;
3379
+ tui.requestRender();
3380
+ return;
3381
+ }
3382
+ done(check.value);
3383
+ };
3384
+ return {
3385
+ render: (width: number) => {
3386
+ if (cached) return cached;
3387
+ const W = Math.max(1, width);
3388
+ const lines: string[] = [];
3389
+ lines.push(theme.fg("accent", "─".repeat(W)));
3390
+ lines.push(
3391
+ ...wrapTextWithAnsi(` ${theme.fg("text", opts.title)}`, W),
3392
+ );
3393
+ if (error)
3394
+ lines.push(
3395
+ ...wrapTextWithAnsi(` ${theme.fg("warning", error)}`, W),
3396
+ );
3397
+ lines.push("");
3398
+ for (const l of editor.render(Math.max(1, W))) lines.push(l);
3399
+ lines.push("");
3400
+ lines.push(` ${theme.fg("dim", "Enter confirm • Esc cancel")}`);
3401
+ lines.push(theme.fg("accent", "─".repeat(W)));
3402
+ cached = lines;
3403
+ return lines;
3404
+ },
3405
+ invalidate: () => {
3406
+ cached = undefined;
3407
+ },
3408
+ handleInput: (data: string) => {
3409
+ if (matchesKey(data, Key.escape)) {
3410
+ done(null);
3411
+ return;
3412
+ }
3413
+ editor.handleInput(data);
3414
+ cached = undefined;
3415
+ tui.requestRender();
3416
+ },
3417
+ };
3418
+ });
3419
+ }
3420
+
3421
+ /**
3422
+ * Profile management picker: activate / none / new / per-profile actions
3423
+ * (edit, rename, delete) and the default-profile entry. Loops so actions
3424
+ * return to the list; every action handles its own persistence.
3425
+ */
3426
+ async function openProfilePicker(ctx: ExtensionContext): Promise<void> {
3427
+ ensureProfilesConfig((ctx as any)?.cwd);
3428
+ ensureRoleConfig((ctx as any)?.cwd);
3429
+ while (true) {
3430
+ const profiles = profilesConfig.profiles;
3431
+ const def = currentDefaultProfileName((ctx as any)?.cwd);
3432
+ const entries: SelectItem[] = [];
3433
+ entries.push({
3434
+ value: "none",
3435
+ label: activeProfileName
3436
+ ? "— none (default roles) —"
3437
+ : "● — none (default roles) —",
3438
+ description: "deactivate any profile; the roles.json flow applies",
3439
+ });
3440
+ for (const p of profiles) {
3441
+ const isActive =
3442
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
3443
+ const isDefault = def?.toLowerCase() === p.name.toLowerCase();
3444
+ const markers = [
3445
+ isActive ? "● active" : "",
3446
+ isDefault ? "★ default" : "",
3447
+ ]
3448
+ .filter(Boolean)
3449
+ .join(", ");
3450
+ const modelSummary = PROFILE_ROLES.map((r) => {
3451
+ const m = p.roles[r];
3452
+ return m ? `${roleIcon(r)} ${formatModelRef(m)}` : null;
3453
+ })
3454
+ .filter(Boolean)
3455
+ .join(" · ");
3456
+ entries.push({
3457
+ value: p.name,
3458
+ label: `⚡ ${p.name}${markers ? ` (${markers})` : ""}`,
3459
+ description: p.description || modelSummary || undefined,
3460
+ });
3461
+ }
3462
+ entries.push({
3463
+ value: "__new",
3464
+ label: "+ New profile…",
3465
+ description: "name it, then pick a model per role (like /role)",
3466
+ });
3467
+ entries.push({
3468
+ value: "__default",
3469
+ label: "Default for new sessions…",
3470
+ description: `workflow.defaultProfile — currently: ${def ?? "(none)"}`,
3471
+ });
3472
+
3473
+ const selected = await ctx.ui.custom<string | null>(
3474
+ (tui, theme, _kb, done) => {
3475
+ const list = new SelectList(entries, 10, listThemeOf(theme));
3476
+ let cached: string[] | undefined;
3477
+ return {
3478
+ render: (width: number) => {
3479
+ if (cached) return cached;
3480
+ const W = Math.max(1, width);
3481
+ const lines: string[] = [];
3482
+ lines.push(theme.fg("accent", "─".repeat(W)));
3483
+ lines.push(
3484
+ ` ${theme.fg("text", "Model Profiles")} ${theme.fg(
3485
+ "muted",
3486
+ `— active: ${activeProfileName ?? "none (default roles)"}`,
3487
+ )}`,
3488
+ );
3489
+ lines.push("");
3490
+ for (const l of list.render(Math.max(1, W - 2)))
3491
+ lines.push(` ${l}`);
3492
+ lines.push("");
3493
+ lines.push(
3494
+ ` ${theme.fg("dim", "Enter: select/action • type to filter • Esc close")}`,
3495
+ );
3496
+ lines.push(theme.fg("accent", "─".repeat(W)));
3497
+ cached = lines;
3498
+ return lines;
3499
+ },
3500
+ invalidate: () => {
3501
+ cached = undefined;
3502
+ },
3503
+ handleInput: (data: string) => {
3504
+ if (matchesKey(data, Key.escape)) {
3505
+ done(null);
3506
+ return;
3507
+ }
3508
+ if (matchesKey(data, Key.enter)) {
3509
+ done(list.getSelectedItem()?.value ?? null);
3510
+ return;
3511
+ }
3512
+ list.handleInput(data);
3513
+ cached = undefined;
3514
+ tui.requestRender();
3515
+ },
3516
+ };
3517
+ },
3518
+ );
3519
+ if (!selected) return; // Esc / closed
3520
+ if (selected === "__new") {
3521
+ const name = await inputProfileName(ctx, {
3522
+ title: "New profile — name (dashes, no spaces):",
3523
+ existing: profilesConfig.profiles.map((p) => p.name),
3524
+ });
3525
+ if (!name) continue;
3526
+ await openProfileEditor(ctx, { createName: name });
3527
+ continue;
3528
+ }
3529
+ if (selected === "__default") {
3530
+ await pickDefaultProfile(ctx);
3531
+ continue;
3532
+ }
3533
+ if (selected === "none") {
3534
+ if (activeProfileName) {
3535
+ await setActiveProfile(ctx as any, null, { explicit: true });
3536
+ } else {
3537
+ ctx.ui.notify("No profile active — default roles flow.", "info");
3538
+ }
3539
+ continue;
3540
+ }
3541
+ // A concrete profile → action menu
3542
+ const profile = getProfile(profilesConfig, selected);
3543
+ if (!profile) continue;
3544
+ const action = await ctx.ui.custom<string | null>(
3545
+ (tui, theme, _kb, done) => {
3546
+ const isActive =
3547
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3548
+ const list = new SelectList(
3549
+ [
3550
+ isActive
3551
+ ? {
3552
+ value: "deactivate",
3553
+ label: "Deactivate",
3554
+ description: "back to default roles (this session)",
3555
+ }
3556
+ : {
3557
+ value: "activate",
3558
+ label: "Activate now",
3559
+ description:
3560
+ "session-scoped; plan/build re-apply immediately",
3561
+ },
3562
+ {
3563
+ value: "edit",
3564
+ label: "Edit models",
3565
+ description: "tabbed editor — model + thinking per role",
3566
+ },
3567
+ {
3568
+ value: "rename",
3569
+ label: "Rename",
3570
+ description: "change the profile name",
3571
+ },
3572
+ {
3573
+ value: "delete",
3574
+ label: "Delete",
3575
+ description: "remove from profiles.json",
3576
+ },
3577
+ { value: "back", label: "Back", description: "return to the profile list" },
3578
+ ],
3579
+ 5,
3580
+ listThemeOf(theme),
3581
+ );
3582
+ let cached: string[] | undefined;
3583
+ return {
3584
+ render: (width: number) => {
3585
+ if (cached) return cached;
3586
+ const W = Math.max(1, width);
3587
+ const lines: string[] = [];
3588
+ lines.push(theme.fg("accent", "─".repeat(W)));
3589
+ lines.push(
3590
+ ` ${theme.fg("text", `⚡ ${profile.name}`)}${
3591
+ profile.description
3592
+ ? theme.fg("muted", ` — ${profile.description}`)
3593
+ : ""
3594
+ }`,
3595
+ );
3596
+ lines.push("");
3597
+ for (const l of list.render(Math.max(1, W - 2)))
3598
+ lines.push(` ${l}`);
3599
+ lines.push("");
3600
+ lines.push(` ${theme.fg("dim", "Enter select • Esc back")}`);
3601
+ lines.push(theme.fg("accent", "─".repeat(W)));
3602
+ cached = lines;
3603
+ return lines;
3604
+ },
3605
+ invalidate: () => {
3606
+ cached = undefined;
3607
+ },
3608
+ handleInput: (data: string) => {
3609
+ if (matchesKey(data, Key.escape)) {
3610
+ done(null);
3611
+ return;
3612
+ }
3613
+ if (matchesKey(data, Key.enter)) {
3614
+ done(list.getSelectedItem()?.value ?? null);
3615
+ return;
3616
+ }
3617
+ list.handleInput(data);
3618
+ cached = undefined;
3619
+ tui.requestRender();
3620
+ },
3621
+ };
3622
+ },
3623
+ );
3624
+ if (!action || action === "back") continue;
3625
+ if (action === "activate") {
3626
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
3627
+ continue;
3628
+ }
3629
+ if (action === "deactivate") {
3630
+ await setActiveProfile(ctx as any, null, { explicit: true });
3631
+ continue;
3632
+ }
3633
+ if (action === "edit") {
3634
+ await openProfileEditor(ctx, { name: profile.name });
3635
+ continue;
3636
+ }
3637
+ if (action === "rename") {
3638
+ const newName = await inputProfileName(ctx, {
3639
+ title: `Rename profile "${profile.name}" to:`,
3640
+ initial: profile.name,
3641
+ existing: profilesConfig.profiles
3642
+ .filter((p) => p !== profile)
3643
+ .map((p) => p.name),
3644
+ });
3645
+ if (newName && newName !== profile.name) {
3646
+ const wasActive =
3647
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3648
+ profile.name = newName;
3649
+ if (wasActive) activeProfileName = profile.name;
3650
+ invalidateEffectiveRoles();
3651
+ try {
3652
+ saveProfiles(profilesConfig, "user");
3653
+ ctx.ui.notify(`Profile renamed → ⚡ ${profile.name}.`, "info");
3654
+ } catch (e: any) {
3655
+ ctx.ui.notify(
3656
+ `Renamed in memory but file save failed: ${String(e?.message || e)}`,
3657
+ "warning",
3658
+ );
3659
+ }
3660
+ updateStatus(ctx as any);
3661
+ }
3662
+ continue;
3663
+ }
3664
+ if (action === "delete") {
3665
+ const confirmed = await confirmProfileDelete(ctx, profile);
3666
+ if (!confirmed) continue;
3667
+ const wasActive =
3668
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3669
+ profilesConfig.profiles = profilesConfig.profiles.filter(
3670
+ (p) => p !== profile,
3671
+ );
3672
+ if (wasActive) {
3673
+ activeProfileName = null;
3674
+ invalidateEffectiveRoles();
3675
+ }
3676
+ try {
3677
+ saveProfiles(profilesConfig, "user");
3678
+ ctx.ui.notify(
3679
+ `Profile ⚡ ${profile.name} deleted.${
3680
+ wasActive
3681
+ ? " It was active — default roles flow restored."
3682
+ : ""
3683
+ }`,
3684
+ "info",
3685
+ );
3686
+ } catch (e: any) {
3687
+ ctx.ui.notify(
3688
+ `Deleted in memory but file save failed: ${String(e?.message || e)}`,
3689
+ "warning",
3690
+ );
3691
+ }
3692
+ if (wasActive && workflowMode) {
3693
+ try {
3694
+ await applyModeModel(ctx as any);
3695
+ } catch (_e) {
3696
+ void _e;
3697
+ }
3698
+ }
3699
+ updateStatus(ctx as any);
3700
+ continue;
3701
+ }
3702
+ }
3703
+ }
3704
+
3705
+ /** Picker for the default profile (workflow.defaultProfile) + its scope. */
3706
+ async function pickDefaultProfile(ctx: ExtensionContext): Promise<void> {
3707
+ const cwd = (ctx as any)?.cwd as string | undefined;
3708
+ const current = currentDefaultProfileName(cwd);
3709
+ const entries: SelectItem[] = [
3710
+ {
3711
+ value: "__none",
3712
+ label: "— none —",
3713
+ description: "new sessions start without a profile",
3714
+ },
3715
+ ...profilesConfig.profiles.map((p) => ({
3716
+ value: p.name,
3717
+ label: `⚡ ${p.name}`,
3718
+ description: p.description || undefined,
3719
+ })),
3720
+ ];
3721
+ const name = await ctx.ui.custom<string | null>(
3722
+ (tui, theme, _kb, done) => {
3723
+ const list = new SelectList(entries, 8, listThemeOf(theme));
3724
+ let cached: string[] | undefined;
3725
+ return {
3726
+ render: (width: number) => {
3727
+ if (cached) return cached;
3728
+ const W = Math.max(1, width);
3729
+ const lines: string[] = [];
3730
+ lines.push(theme.fg("accent", "─".repeat(W)));
3731
+ lines.push(
3732
+ ` ${theme.fg("text", "Default profile for new sessions")} ${theme.fg(
3733
+ "muted",
3734
+ `— currently: ${current ?? "(none)"}`,
3735
+ )}`,
3736
+ );
3737
+ lines.push("");
3738
+ for (const l of list.render(Math.max(1, W - 2)))
3739
+ lines.push(` ${l}`);
3740
+ lines.push("");
3741
+ lines.push(` ${theme.fg("dim", "Enter select • Esc cancel")}`);
3742
+ lines.push(theme.fg("accent", "─".repeat(W)));
3743
+ cached = lines;
3744
+ return lines;
3745
+ },
3746
+ invalidate: () => {
3747
+ cached = undefined;
3748
+ },
3749
+ handleInput: (data: string) => {
3750
+ if (matchesKey(data, Key.escape)) {
3751
+ done(null);
3752
+ return;
3753
+ }
3754
+ if (matchesKey(data, Key.enter)) {
3755
+ done(list.getSelectedItem()?.value ?? null);
3756
+ return;
3757
+ }
3758
+ list.handleInput(data);
3759
+ cached = undefined;
3760
+ tui.requestRender();
3761
+ },
3762
+ };
3763
+ },
3764
+ );
3765
+ if (name === null) return;
3766
+ // Keep the scope where the setting currently lives (else global).
3767
+ const resolved = readDefaultProfileSetting(
3768
+ cwd ?? process.cwd(),
3769
+ getAgentDir(),
3770
+ );
3771
+ const scope: "global" | "project" =
3772
+ resolved.sources.effective === "project" ? "project" : "global";
3773
+ const value = name === "__none" ? null : name;
3774
+ const res = writeDefaultProfileSetting(
3775
+ scope,
3776
+ cwd ?? process.cwd(),
3777
+ getAgentDir(),
3778
+ value,
3779
+ );
3780
+ if (!res.ok) {
3781
+ ctx.ui.notify(
3782
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
3783
+ "error",
3784
+ );
3785
+ return;
3786
+ }
3787
+ ctx.ui.notify(
3788
+ value
3789
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
3790
+ : `Default profile cleared (${scope} settings).`,
3791
+ "info",
3792
+ );
3793
+ updateStatus(ctx as any);
3794
+ }
3795
+
3796
+ interface StagedProfileModel {
3797
+ provider: string;
3798
+ id: string;
3799
+ thinking: ThinkingLevel;
3800
+ }
3801
+
3802
+ /**
3803
+ * Tabbed profile editor — one tab per built-in role (planner / explorer /
3804
+ * builder / reviewer, mirroring /role) plus two submit tabs: Save &
3805
+ * activate and Save only. Selections stage locally; saving writes
3806
+ * profiles.json (user scope). `createName` creates a new profile seeded
3807
+ * from the current effective roles; `name` edits an existing one.
3808
+ */
3809
+ async function openProfileEditor(
3810
+ ctx: ExtensionContext,
3811
+ opts: { createName?: string; name?: string },
3812
+ ): Promise<void> {
3813
+ ensureProfilesConfig((ctx as any)?.cwd);
3814
+ ensureRoleConfig((ctx as any)?.cwd);
3815
+ const catalog = collectRoleCatalog(ctx);
3816
+ if (catalog.all.length === 0) {
3817
+ ctx.ui.notify(
3818
+ "No models available from connected providers — connect a provider first.",
3819
+ "warning",
3820
+ );
3821
+ return;
3822
+ }
3823
+ const existing = opts.name
3824
+ ? getProfile(profilesConfig, opts.name)
3825
+ : undefined;
3826
+ if (opts.name && !existing) {
3827
+ ctx.ui.notify(`Profile "${opts.name}" no longer exists.`, "warning");
3828
+ return;
3829
+ }
3830
+ const isNew = !existing;
3831
+ const profileName = existing
3832
+ ? existing.name
3833
+ : (opts.createName ?? "").trim();
3834
+ if (!profileName) {
3835
+ ctx.ui.notify("Profile name required.", "warning");
3836
+ return;
3837
+ }
3838
+ const roleMeta = new Map<string, { name: string; description: string }>();
3839
+ for (const roleName of PROFILE_ROLES) {
3840
+ const base = getRole(roleConfig, roleName);
3841
+ roleMeta.set(roleName, {
3842
+ name: roleName,
3843
+ description: base?.description ?? `Role ${roleName}`,
3844
+ });
3845
+ }
3846
+ // Keep the narrow tuple type so profile.roles[roleName] typechecks.
3847
+ const tabIds = [...PROFILE_ROLES];
3848
+ // Stage from the profile (edit) or the current effective roles (create).
3849
+ const staged = new Map<string, StagedProfileModel>();
3850
+ for (const roleName of PROFILE_ROLES) {
3851
+ const src =
3852
+ existing?.roles[roleName] ?? getEffectiveRole(roleName)?.model;
3853
+ staged.set(roleName, {
3854
+ provider: src?.provider ?? "",
3855
+ id: src?.id ?? "",
3856
+ thinking: src?.thinking ?? "medium",
3857
+ });
3858
+ }
3859
+
3860
+ overlayActive = true;
3861
+ let saved: {
3862
+ activate: boolean;
3863
+ staged: Map<string, StagedProfileModel>;
3864
+ } | null = null;
3865
+ try {
3866
+ saved = await ctx.ui.custom<
3867
+ { activate: boolean; staged: Map<string, StagedProfileModel> } | null
3868
+ >((tui, theme, _kb, done) => {
3869
+ let currentTab = 0;
3870
+ let phase: "model" | "thinking" = "model";
3871
+ let showAll = catalog.scoped.length === 0;
3872
+ let cached: string[] | undefined;
3873
+ const themeList = listThemeOf(theme);
3874
+ const isSubmitTab = () => currentTab >= tabIds.length;
3875
+ const isSaveActivate = () => currentTab === tabIds.length;
3876
+ const buildModelItems = (roleName: string): SelectItem[] => {
3877
+ const st = staged.get(roleName)!;
3878
+ const cur = `${st.provider}/${st.id}`;
3879
+ const src = showAll ? catalog.all : catalog.scoped;
3880
+ return src.map((e) => {
3881
+ const ref = `${e.provider}/${e.id}`;
3882
+ const description: string | undefined =
3883
+ ref === cur
3884
+ ? "● selected"
3885
+ : showAll && !e.inScope
3886
+ ? "all"
3887
+ : undefined;
3888
+ return { value: ref, label: ref, description };
3889
+ });
3890
+ };
3891
+ const modelLists = new Map<string, SelectList>();
3892
+ const thinkingList = new SelectList(
3893
+ VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
3894
+ Math.min(VALID_THINKING_LEVELS.length, 8),
3895
+ themeList,
3896
+ );
3897
+ const getModelList = (roleName: string): SelectList => {
3898
+ let list = modelLists.get(roleName);
3899
+ if (!list) {
3900
+ list = new SelectList(buildModelItems(roleName), 10, themeList);
3901
+ const st = staged.get(roleName)!;
3902
+ const idx = (showAll ? catalog.all : catalog.scoped).findIndex(
3903
+ (e) => `${e.provider}/${e.id}` === `${st.provider}/${st.id}`,
3904
+ );
3905
+ if (idx > 0) list.setSelectedIndex(idx);
3906
+ modelLists.set(roleName, list);
3907
+ }
3908
+ return list;
3909
+ };
3910
+ const rebuildModelLists = () => modelLists.clear();
3911
+ const syncThinkingCursor = (roleName: string) => {
3912
+ const st = staged.get(roleName)!;
3913
+ const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
3914
+ if (idx >= 0) thinkingList.setSelectedIndex(idx);
3915
+ };
3916
+ const commitModelSelection = (roleName: string) => {
3917
+ const sel = getModelList(roleName).getSelectedItem();
3918
+ if (!sel) return false;
3919
+ const { provider, id } = parseModelRef(sel.value);
3920
+ if (!provider || !id) return false;
3921
+ const st = staged.get(roleName)!;
3922
+ staged.set(roleName, { ...st, provider, id });
3923
+ return true;
3924
+ };
3925
+ const commitThinkingSelection = (roleName: string) => {
3926
+ const sel = thinkingList.getSelectedItem();
3927
+ if (!sel || !isValidThinkingLevel(sel.value)) return false;
3928
+ const st = staged.get(roleName)!;
3929
+ staged.set(roleName, { ...st, thinking: sel.value });
3930
+ return true;
3931
+ };
3932
+ const advanceFromThinking = () => {
3933
+ if (currentTab < tabIds.length) currentTab++;
3934
+ phase = "model";
3935
+ cached = undefined;
3936
+ tui.requestRender();
3937
+ };
3938
+
3939
+ const handleInput = (data: string) => {
3940
+ if (matchesKey(data, Key.escape)) {
3941
+ if (phase === "thinking" && !isSubmitTab()) {
3942
+ phase = "model";
3943
+ cached = undefined;
3944
+ tui.requestRender();
3945
+ return;
3946
+ }
3947
+ done(null);
3948
+ return;
3949
+ }
3950
+ if (
3951
+ matchesKey(data, Key.tab) ||
3952
+ matchesKey(data, Key.right) ||
3953
+ matchesKey(data, Key.shift("tab")) ||
3954
+ matchesKey(data, Key.left)
3955
+ ) {
3956
+ const fwd =
3957
+ matchesKey(data, Key.tab) || matchesKey(data, Key.right);
3958
+ const n = tabIds.length + 2;
3959
+ currentTab = fwd
3960
+ ? (currentTab + 1) % n
3961
+ : (currentTab - 1 + n) % n;
3962
+ phase = "model";
3963
+ cached = undefined;
3964
+ tui.requestRender();
3965
+ return;
3966
+ }
3967
+ // Ctrl+O toggles scoped ↔ all models
3968
+ try {
3969
+ if (matchesKey(data, Key.ctrl("o"))) {
3970
+ showAll = !showAll;
3971
+ rebuildModelLists();
3972
+ cached = undefined;
3973
+ tui.requestRender();
3974
+ return;
3975
+ }
3976
+ } catch (_e) {
3977
+ void _e;
3978
+ }
3979
+ if (isSubmitTab()) {
3980
+ if (matchesKey(data, Key.enter)) {
3981
+ done({ activate: isSaveActivate(), staged: new Map(staged) });
3982
+ }
3983
+ return;
3984
+ }
3985
+ const roleName = tabIds[currentTab];
3986
+ if (matchesKey(data, Key.enter)) {
3987
+ if (phase === "model") {
3988
+ if (!commitModelSelection(roleName)) return;
3989
+ phase = "thinking";
3990
+ syncThinkingCursor(roleName);
3991
+ cached = undefined;
3992
+ tui.requestRender();
3993
+ return;
3994
+ }
3995
+ if (!commitThinkingSelection(roleName)) return;
3996
+ advanceFromThinking();
3997
+ return;
3998
+ }
3999
+ if (phase === "model") getModelList(roleName).handleInput(data);
4000
+ else thinkingList.handleInput(data);
4001
+ cached = undefined;
4002
+ tui.requestRender();
4003
+ };
4004
+
4005
+ const render = (width: number): string[] => {
4006
+ if (cached) return cached;
4007
+ const lines: string[] = [];
4008
+ const W = Math.max(1, width);
4009
+ const wrap = (s: string) => {
4010
+ const out: string[] = [];
4011
+ for (const line of s.split("\n"))
4012
+ out.push(...wrapTextWithAnsi(line, W));
4013
+ return out;
4014
+ };
4015
+ lines.push(theme.fg("accent", "─".repeat(W)));
4016
+ lines.push(
4017
+ ` ${theme.fg(
4018
+ "text",
4019
+ `${isNew ? "New profile" : "Edit profile"} ⚡ ${profileName}`,
4020
+ )}`,
4021
+ );
4022
+ lines.push("");
4023
+ // Tabs row: one block per role + two submit tabs
4024
+ const chips: string[] = [];
4025
+ for (let i = 0; i < tabIds.length; i++) {
4026
+ const roleName = tabIds[i];
4027
+ const st = staged.get(roleName)!;
4028
+ const base = existing?.roles[roleName];
4029
+ const dirty =
4030
+ !base ||
4031
+ `${st.provider}/${st.id}` !== `${base.provider}/${base.id}` ||
4032
+ st.thinking !== base.thinking;
4033
+ const box = dirty ? "■" : "□";
4034
+ const lbl = ` ${box} ${roleIcon(roleName)} ${roleName} `;
4035
+ chips.push(
4036
+ i === currentTab
4037
+ ? theme.bg("selectedBg", theme.fg("text", lbl))
4038
+ : theme.fg(dirty ? "success" : "muted", lbl),
4039
+ );
4040
+ }
4041
+ const saveActivateLbl = " ✓ Save & activate ";
4042
+ const saveOnlyLbl = " 💾 Save only ";
4043
+ chips.push(
4044
+ isSaveActivate()
4045
+ ? theme.bg("selectedBg", theme.fg("text", saveActivateLbl))
4046
+ : theme.fg("success", saveActivateLbl),
4047
+ );
4048
+ chips.push(
4049
+ currentTab === tabIds.length + 1
4050
+ ? theme.bg("selectedBg", theme.fg("text", saveOnlyLbl))
4051
+ : theme.fg("success", saveOnlyLbl),
4052
+ );
4053
+ lines.push(` ${chips.join("")}`);
4054
+ lines.push("");
4055
+ if (isSubmitTab()) {
4056
+ lines.push(
4057
+ ...wrap(
4058
+ ` ${theme.fg(
4059
+ "text",
4060
+ isSaveActivate()
4061
+ ? "Save & activate — Enter saves and switches this session to the profile; Esc cancels"
4062
+ : "Save only — Enter saves without activating; Esc cancels",
4063
+ )}`,
4064
+ ),
4065
+ );
4066
+ lines.push("");
4067
+ for (const roleName of tabIds) {
4068
+ const st = staged.get(roleName)!;
4069
+ lines.push(
4070
+ ...wrap(
4071
+ ` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
4072
+ "text",
4073
+ st.provider
4074
+ ? `${st.provider}/${st.id} (${st.thinking})`
4075
+ : "(unset)",
4076
+ )}`,
4077
+ ),
4078
+ );
4079
+ }
4080
+ } else {
4081
+ const roleName = tabIds[currentTab];
4082
+ const st = staged.get(roleName)!;
4083
+ const meta = roleMeta.get(roleName)!;
4084
+ lines.push(
4085
+ ...wrap(
4086
+ ` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
4087
+ ),
4088
+ );
4089
+ lines.push(
4090
+ ...wrap(
4091
+ ` ${theme.fg(
4092
+ "muted",
4093
+ `staged: ${
4094
+ st.provider
4095
+ ? `${st.provider}/${st.id} (${st.thinking})`
4096
+ : "(unset)"
4097
+ }`,
4098
+ )}`,
4099
+ ),
4100
+ );
4101
+ lines.push("");
4102
+ if (phase === "model") {
4103
+ const scopeLabel = showAll
4104
+ ? `all ${String(catalog.all.length)}${catalog.truncated ? "+" : ""}`
4105
+ : `scoped ${String(catalog.scoped.length)}`;
4106
+ const toggleHint =
4107
+ catalog.scoped.length > 0 && !showAll
4108
+ ? " — Ctrl+O for all"
4109
+ : "";
4110
+ lines.push(
4111
+ ...wrap(
4112
+ ` ${theme.fg(
4113
+ "accent",
4114
+ `Model (${scopeLabel}${toggleHint}) — type to filter:`,
4115
+ )}`,
4116
+ ),
4117
+ );
4118
+ for (const l of getModelList(roleName).render(Math.max(1, W - 2)))
4119
+ lines.push(` ${l}`);
4120
+ } else {
4121
+ lines.push(
4122
+ ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
4123
+ );
4124
+ for (const l of thinkingList.render(Math.max(1, W - 2)))
4125
+ lines.push(` ${l}`);
4126
+ }
4127
+ }
4128
+ lines.push("");
4129
+ lines.push(
4130
+ ...wrap(
4131
+ ` ${theme.fg(
4132
+ "dim",
4133
+ "Tab/←→ roles • ↑↓ navigate • type to filter • Enter select • Esc back/cancel • Ctrl+O scoped/all",
4134
+ )}`,
4135
+ ),
4136
+ );
4137
+ lines.push(theme.fg("accent", "─".repeat(W)));
4138
+ cached = lines;
4139
+ return lines;
4140
+ };
4141
+
4142
+ return {
4143
+ render,
4144
+ invalidate: () => {
4145
+ cached = undefined;
4146
+ },
4147
+ handleInput,
4148
+ };
4149
+ });
4150
+ } finally {
4151
+ overlayActive = false;
4152
+ }
4153
+ if (!saved) {
4154
+ ctx.ui.notify(
4155
+ isNew ? "Profile creation cancelled." : "Profile editor cancelled.",
4156
+ "info",
4157
+ );
4158
+ return;
4159
+ }
4160
+ // Validate against registry (warn-only, matching /role set).
4161
+ try {
4162
+ const reg = (ctx as any)?.modelRegistry;
4163
+ if (reg && typeof reg.find === "function") {
4164
+ for (const [roleName, st] of saved.staged) {
4165
+ if (
4166
+ st.provider &&
4167
+ st.id &&
4168
+ typeof reg.find === "function" &&
4169
+ !reg.find(st.provider, st.id)
4170
+ )
4171
+ ctx.ui.notify(
4172
+ `Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
4173
+ "warning",
4174
+ );
4175
+ }
4176
+ }
4177
+ } catch (_e) {
4178
+ void _e;
4179
+ }
4180
+ // Materialize: create or update the profile, then persist.
4181
+ let profile = existing;
4182
+ if (!profile) {
4183
+ profile = { name: profileName, description: "", roles: {} };
4184
+ profilesConfig.profiles.push(profile);
4185
+ }
4186
+ profile.roles = {};
4187
+ for (const roleName of PROFILE_ROLES) {
4188
+ const st = saved.staged.get(roleName);
4189
+ if (st && st.provider && st.id)
4190
+ profile.roles[roleName] = {
4191
+ provider: st.provider,
4192
+ id: st.id,
4193
+ thinking: st.thinking,
4194
+ };
4195
+ }
4196
+ try {
4197
+ saveProfiles(profilesConfig, "user");
4198
+ } catch (e: any) {
4199
+ ctx.ui.notify(
4200
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4201
+ "warning",
4202
+ );
4203
+ return;
4204
+ }
4205
+ invalidateEffectiveRoles();
4206
+ updateStatus(ctx as any);
4207
+ if (saved.activate) {
4208
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
4209
+ } else {
4210
+ ctx.ui.notify(
4211
+ `Profile ⚡ ${profile.name} saved. Activate with /profile use ${profile.name}.`,
4212
+ "info",
4213
+ );
4214
+ }
4215
+ }
4216
+
4217
+ // ── Model profiles command ───────────────────────────────────────
4218
+
4219
+ pi.registerCommand("profile", {
4220
+ description:
4221
+ "Switch or manage model profiles (named role-model combinations) — opens picker UI",
4222
+ handler: async (args, ctx) => {
4223
+ const cwd = (ctx as any)?.cwd as string | undefined;
4224
+ ensureRoleConfig(cwd);
4225
+ ensureProfilesConfig(cwd);
4226
+ const parts = String(args || "")
4227
+ .trim()
4228
+ .split(/\s+/)
4229
+ .filter(Boolean);
4230
+ const sub = (parts[0] || "").toLowerCase();
4231
+ const rest = parts.slice(1);
4232
+
4233
+ const persistAndNotify = (msg: string): boolean => {
4234
+ try {
4235
+ saveProfiles(profilesConfig, "user");
4236
+ } catch (e: any) {
4237
+ ctx.ui.notify(
4238
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4239
+ "warning",
4240
+ );
4241
+ return false;
4242
+ }
4243
+ invalidateEffectiveRoles();
4244
+ updateStatus(ctx as any);
4245
+ ctx.ui.notify(msg, "info");
4246
+ return true;
4247
+ };
4248
+
4249
+ // Bare /profile → picker UI (headless → text summary).
4250
+ if (!sub) {
4251
+ if ((ctx as any)?.hasUI) {
4252
+ await openProfilePicker(ctx as any);
4253
+ return;
4254
+ }
4255
+ ctx.ui.notify(
4256
+ formatProfilesForDisplay(
4257
+ profilesConfig,
4258
+ activeProfileName,
4259
+ currentDefaultProfileName(cwd),
4260
+ ),
4261
+ "info",
4262
+ );
4263
+ return;
4264
+ }
4265
+
4266
+ if (sub === "list" || (sub === "show" && !rest[0])) {
4267
+ ctx.ui.notify(
4268
+ formatProfilesForDisplay(
4269
+ profilesConfig,
4270
+ activeProfileName,
4271
+ currentDefaultProfileName(cwd),
4272
+ ),
4273
+ "info",
4274
+ );
4275
+ return;
4276
+ }
4277
+
4278
+ if (sub === "show") {
4279
+ const p = getProfile(profilesConfig, rest[0]);
4280
+ if (!p) {
4281
+ ctx.ui.notify(`Unknown profile "${rest[0]}".`, "warning");
4282
+ return;
4283
+ }
4284
+ ctx.ui.notify(describeProfile(p), "info");
4285
+ return;
4286
+ }
4287
+
4288
+ if (sub === "use" || sub === "activate") {
4289
+ const name = rest[0];
4290
+ if (
4291
+ !name ||
4292
+ (name.toLowerCase() !== "none" &&
4293
+ name.toLowerCase() !== "off" &&
4294
+ !getProfile(profilesConfig, name))
4295
+ ) {
4296
+ ctx.ui.notify(
4297
+ `Usage: /profile use <name|none>. Available: ${
4298
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
4299
+ "(none — /profile create <name>)"
4300
+ }`,
4301
+ "info",
4302
+ );
4303
+ return;
4304
+ }
4305
+ await setActiveProfile(
4306
+ ctx as any,
4307
+ name.toLowerCase() === "none" || name.toLowerCase() === "off"
4308
+ ? null
4309
+ : name,
4310
+ { explicit: true },
4311
+ );
4312
+ return;
4313
+ }
4314
+
4315
+ if (sub === "create" || sub === "new" || sub === "add") {
4316
+ const rawName = rest[0];
4317
+ if (!rawName) {
4318
+ ctx.ui.notify("Usage: /profile create <name>", "info");
4319
+ return;
4320
+ }
4321
+ const check = validateProfileName(
4322
+ rawName,
4323
+ profilesConfig.profiles.map((p) => p.name),
4324
+ );
4325
+ if (!check.ok) {
4326
+ ctx.ui.notify(`Cannot create profile: ${check.error}`, "warning");
4327
+ return;
4328
+ }
4329
+ if ((ctx as any)?.hasUI) {
4330
+ await openProfileEditor(ctx as any, { createName: check.value });
4331
+ return;
4332
+ }
4333
+ // Headless: create seeded from the current effective roles.
4334
+ const seed = buildProfileSeed(check.value);
4335
+ profilesConfig.profiles.push(seed);
4336
+ persistAndNotify(
4337
+ `Profile ⚡ ${seed.name} created (seeded from the current effective models). Activate with /profile use ${seed.name}.`,
4338
+ );
4339
+ return;
4340
+ }
4341
+
4342
+ if (sub === "edit" || sub === "config") {
4343
+ const name = rest[0];
4344
+ if (!name) {
4345
+ ctx.ui.notify("Usage: /profile edit <name>", "info");
4346
+ return;
4347
+ }
4348
+ const p = getProfile(profilesConfig, name);
4349
+ if (!p) {
4350
+ ctx.ui.notify(
4351
+ `Unknown profile "${name}". Available: ${
4352
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4353
+ }`,
4354
+ "warning",
4355
+ );
4356
+ return;
4357
+ }
4358
+ if (!(ctx as any)?.hasUI) {
4359
+ ctx.ui.notify(describeProfile(p) + "\n\nEditor needs UI.", "info");
4360
+ return;
4361
+ }
4362
+ await openProfileEditor(ctx as any, { name: p.name });
4363
+ return;
4364
+ }
4365
+
4366
+ if (sub === "rename" || sub === "mv") {
4367
+ const [oldName, newName] = rest;
4368
+ if (!oldName || !newName) {
4369
+ ctx.ui.notify("Usage: /profile rename <old> <new>", "info");
4370
+ return;
4371
+ }
4372
+ const p = getProfile(profilesConfig, oldName);
4373
+ if (!p) {
4374
+ ctx.ui.notify(`Unknown profile "${oldName}".`, "warning");
4375
+ return;
4376
+ }
4377
+ const check = validateProfileName(
4378
+ newName,
4379
+ profilesConfig.profiles
4380
+ .filter((q) => q !== p)
4381
+ .map((q) => q.name),
4382
+ );
4383
+ if (!check.ok) {
4384
+ ctx.ui.notify(`Cannot rename: ${check.error}`, "warning");
4385
+ return;
4386
+ }
4387
+ const wasActive =
4388
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4389
+ p.name = check.value;
4390
+ if (wasActive) activeProfileName = p.name;
4391
+ invalidateEffectiveRoles();
4392
+ persistAndNotify(
4393
+ `Profile renamed → ⚡ ${p.name}${
4394
+ wasActive ? " (still active in this session)" : ""
4395
+ }`,
4396
+ );
4397
+ const def = readDefaultProfileSetting(
4398
+ cwd ?? process.cwd(),
4399
+ getAgentDir(),
4400
+ );
4401
+ if (def.value && def.value.toLowerCase() === oldName.toLowerCase()) {
4402
+ ctx.ui.notify(
4403
+ `workflow.defaultProfile still points at "${def.value}" — update with /profile default ${p.name}${
4404
+ def.sources.effective === "project" ? " project" : ""
4405
+ }`,
4406
+ "warning",
4407
+ );
4408
+ }
4409
+ return;
4410
+ }
4411
+
4412
+ if (sub === "delete" || sub === "remove" || sub === "rm") {
4413
+ const name = rest[0];
4414
+ if (!name) {
4415
+ ctx.ui.notify("Usage: /profile delete <name>", "info");
4416
+ return;
4417
+ }
4418
+ const p = getProfile(profilesConfig, name);
4419
+ if (!p) {
4420
+ ctx.ui.notify(`Unknown profile "${name}".`, "warning");
4421
+ return;
4422
+ }
4423
+ if ((ctx as any)?.hasUI) {
4424
+ const confirmed = await confirmProfileDelete(ctx as any, p);
4425
+ if (!confirmed) {
4426
+ ctx.ui.notify("Deletion cancelled.", "info");
4427
+ return;
4428
+ }
4429
+ }
4430
+ const wasActive =
4431
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4432
+ profilesConfig.profiles = profilesConfig.profiles.filter(
4433
+ (q) => q !== p,
4434
+ );
4435
+ if (wasActive) {
4436
+ activeProfileName = null;
4437
+ invalidateEffectiveRoles();
4438
+ }
4439
+ const savedOk = persistAndNotify(
4440
+ `Profile ⚡ ${p.name} deleted.${
4441
+ wasActive ? " It was active — default roles flow restored." : ""
4442
+ }`,
4443
+ );
4444
+ if (savedOk && wasActive && workflowMode) {
4445
+ try {
4446
+ await applyModeModel(ctx as any);
4447
+ updateStatus(ctx as any);
4448
+ } catch (_e) {
4449
+ void _e;
4450
+ }
4451
+ }
4452
+ return;
4453
+ }
4454
+
4455
+ if (sub === "default") {
4456
+ // /profile default [name|none] [global|project]
4457
+ const target = rest.find(
4458
+ (t) => t.toLowerCase() !== "global" && t.toLowerCase() !== "project",
4459
+ );
4460
+ const scope: "global" | "project" = rest
4461
+ .map((t) => t.toLowerCase())
4462
+ .includes("project")
4463
+ ? "project"
4464
+ : "global";
4465
+ const paths = defaultProfileSettingPaths(
4466
+ cwd ?? process.cwd(),
4467
+ getAgentDir(),
4468
+ );
4469
+ if (!target) {
4470
+ const resolved = readDefaultProfileSetting(
4471
+ cwd ?? process.cwd(),
4472
+ getAgentDir(),
4473
+ );
4474
+ ctx.ui.notify(
4475
+ [
4476
+ `Default profile (workflow.defaultProfile): ${
4477
+ resolved.value ?? "(none)"
4478
+ } (${resolved.sources.effective})`,
4479
+ `Global settings: ${paths.global}`,
4480
+ `Project settings: ${paths.project}`,
4481
+ "New sessions without an explicit /profile use start on the default profile.",
4482
+ "Usage: /profile default <name|none> [global|project]",
4483
+ ].join("\n"),
4484
+ "info",
4485
+ );
4486
+ return;
4487
+ }
4488
+ const value =
4489
+ target.toLowerCase() === "none" || target.toLowerCase() === "off"
4490
+ ? null
4491
+ : target;
4492
+ if (value && !getProfile(profilesConfig, value)) {
4493
+ ctx.ui.notify(
4494
+ `Unknown profile "${value}". Available: ${
4495
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4496
+ }`,
4497
+ "warning",
4498
+ );
4499
+ return;
4500
+ }
4501
+ const res = writeDefaultProfileSetting(
4502
+ scope,
4503
+ cwd ?? process.cwd(),
4504
+ getAgentDir(),
4505
+ value,
4506
+ );
4507
+ if (!res.ok) {
4508
+ ctx.ui.notify(
4509
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
4510
+ "error",
4511
+ );
4512
+ return;
4513
+ }
4514
+ ctx.ui.notify(
4515
+ value
4516
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
4517
+ : `Default profile cleared (${scope} settings).`,
4518
+ "info",
4519
+ );
4520
+ updateStatus(ctx as any);
4521
+ return;
4522
+ }
4523
+
4524
+ // Bare profile name → show detail (mirrors /role <name>).
4525
+ const named = getProfile(profilesConfig, sub);
4526
+ if (named) {
4527
+ ctx.ui.notify(describeProfile(named), "info");
4528
+ return;
4529
+ }
4530
+ ctx.ui.notify(PROFILE_USAGE, "info");
4531
+ },
4532
+ 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;
4608
+ },
4609
+ });
4610
+
4611
+ // ── Role config screen (tabbed picker: one block per role) ─────────
4612
+ //
4613
+ // One overlay with a tab per role (built-ins first, then customs) plus
4614
+ // a Submit tab. Each role tab has two phases: pick model (from the
4615
+ // connected providers — scoped models first, Ctrl+O for all) then pick
4616
+ // thinking level. Selections stage locally; Submit saves to roles.json.
4617
+
4618
+ interface StagedRoleModel {
4619
+ provider: string;
4620
+ id: string;
4621
+ thinking: ThinkingLevel;
4622
+ }
4623
+
4624
+ interface RoleCatalogEntry {
4625
+ provider: string;
4626
+ id: string;
4627
+ inScope: boolean;
4628
+ }
4629
+
4630
+ function collectRoleCatalog(ctx: ExtensionContext): {
4631
+ scoped: RoleCatalogEntry[];
4632
+ all: RoleCatalogEntry[];
4633
+ truncated: boolean;
4634
+ } {
4635
+ const scoped: RoleCatalogEntry[] = [];
4636
+ try {
4637
+ const raw = ((ctx as any)?.scopedModels ?? []) as any[];
4638
+ for (const s of raw) {
4639
+ const m = s?.model ?? s;
4640
+ if (m && typeof m.provider === "string" && typeof m.id === "string")
4641
+ scoped.push({ provider: m.provider, id: m.id, inScope: true });
4642
+ }
4643
+ } catch (_e) {
4644
+ void _e;
4645
+ }
4646
+ let available: any[] = [];
4647
+ try {
4648
+ const reg = (ctx as any)?.modelRegistry;
4649
+ if (reg && typeof reg.getAvailable === "function")
4650
+ available = [...reg.getAvailable()];
4651
+ else if (reg && typeof reg.getAll === "function")
4652
+ available = [...reg.getAll()];
4653
+ } catch (_e) {
4654
+ void _e;
4655
+ }
4656
+ const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
4657
+ const rest: RoleCatalogEntry[] = [];
4658
+ for (const m of available) {
4659
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
4660
+ continue;
4661
+ const key = `${m.provider}/${m.id}`;
4662
+ if (seen.has(key)) continue;
4663
+ seen.add(key);
4664
+ rest.push({ provider: m.provider, id: m.id, inScope: false });
4665
+ }
4666
+ const CAP = 200;
4667
+ const all = [...scoped, ...rest];
4668
+ return {
4669
+ scoped,
4670
+ all: all.slice(0, CAP),
4671
+ truncated: all.length > CAP,
4672
+ };
4673
+ }
4674
+
4675
+ function orderedRoleTabs(): Role[] {
4676
+ // Built-in roles first in their declared order, then custom roles.
4677
+ // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
4678
+ const rank = (name: string) => {
4679
+ const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
4680
+ name.toLowerCase(),
4681
+ );
4682
+ return i === -1 ? BUILT_IN_ROLES.length : i;
4683
+ };
4684
+ return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
4685
+ }
4686
+
4687
+ async function openRolePicker(ctx: ExtensionContext): Promise<void> {
4688
+ const tabs = orderedRoleTabs();
4689
+ if (tabs.length === 0) {
4690
+ ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
4691
+ return;
4692
+ }
4693
+ const catalog = collectRoleCatalog(ctx);
4694
+ if (catalog.all.length === 0) {
4695
+ ctx.ui.notify(
4696
+ "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
4697
+ "warning",
4698
+ );
4699
+ return;
4700
+ }
4701
+ // Stage current values so the picker opens on the active selection.
4702
+ const staged = new Map<string, StagedRoleModel>();
4703
+ for (const r of tabs) staged.set(r.name, { ...r.model });
4704
+
4705
+ overlayActive = true;
4706
+ let saved: Map<string, StagedRoleModel> | null = null;
4707
+ try {
4708
+ saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
4709
+ (tui, theme, _kb, done) => {
4710
+ let currentTab = 0; // tabs.length = Submit
4711
+ let phase: "model" | "thinking" = "model";
4712
+ let showAll = catalog.scoped.length === 0;
4713
+ let cached: string[] | undefined;
4714
+ // One SelectList per role tab for models + one shared for thinking,
4715
+ // so filter text + cursor survive tab switches.
4716
+ const listTheme = {
4717
+ selectedPrefix: (t: string) => theme.fg("accent", t),
4718
+ selectedText: (t: string) => theme.fg("accent", t),
4719
+ description: (t: string) => theme.fg("muted", t),
4720
+ scrollInfo: (t: string) => theme.fg("dim", t),
4721
+ noMatch: (t: string) => theme.fg("warning", t),
4722
+ };
4723
+ const currentRef = (role: Role) =>
4724
+ `${role.model.provider}/${role.model.id}`;
4725
+ const buildModelItems = (role: Role): SelectItem[] => {
4726
+ const st = staged.get(role.name)!;
4727
+ const cur = `${st.provider}/${st.id}`;
4728
+ const src = showAll ? catalog.all : catalog.scoped;
4729
+ return src.map((e) => {
2942
4730
  const ref = `${e.provider}/${e.id}`;
2943
4731
  let description: string | undefined;
2944
4732
  if (ref === cur) {
@@ -3086,6 +4874,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
3086
4874
  return out;
3087
4875
  };
3088
4876
  lines.push(theme.fg("accent", "─".repeat(W)));
4877
+ // Profile notice: /role edits the BASE roles; an active profile
4878
+ // overlays models on top (see effectiveRoles).
4879
+ if (activeProfileName) {
4880
+ lines.push(
4881
+ ...wrap(
4882
+ ` ${theme.fg("warning", `Profile ⚡ ${activeProfileName} active — editing BASE roles; profile overrides apply on top. /profile edit ${activeProfileName} to change the profile.`)}`,
4883
+ ),
4884
+ );
4885
+ lines.push("");
4886
+ }
3089
4887
  // Tabs row: one block per role + Submit
3090
4888
  const chips: string[] = [];
3091
4889
  for (let i = 0; i < tabs.length; i++) {
@@ -3321,7 +5119,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
3321
5119
  // Tasks whose agent has no explicit model use the explorer role's
3322
5120
  // single model. Telemetry lands in details.
3323
5121
  ensureRoleConfig(cwd);
3324
- const explorerRole = getRole(roleConfig, "explorer");
5122
+ ensureProfilesConfig(cwd);
5123
+ // Active profile's explorer model when a profile is active.
5124
+ const explorerRole = getEffectiveRole("explorer");
3325
5125
  const routedMeta: { role: string; model: string; reason: string }[] =
3326
5126
  new Array(jobs.length);
3327
5127
  const routedAgents: (AgentConfig | undefined)[] = new Array(jobs.length);
@@ -4353,10 +6153,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
4353
6153
  pi.on("session_start", async (event, ctx) => {
4354
6154
  // Roles are file-backed; load them up-front so every read path (status,
4355
6155
  // band label, reviewer runtime) sees the real config even in a fresh
4356
- // session that has no persisted workflow entry yet.
6156
+ // session that has no persisted workflow entry yet. Profiles likewise.
4357
6157
  try {
4358
6158
  ensureRoleConfig((ctx as any)?.cwd);
4359
6159
  notifyRoleConfigFallback(ctx as ExtensionContext);
6160
+ ensureProfilesConfig((ctx as any)?.cwd);
6161
+ notifyProfilesFallback(ctx as ExtensionContext);
4360
6162
  } catch (_e) {
4361
6163
  void _e;
4362
6164
  }
@@ -4386,13 +6188,42 @@ export default function workflowExtension(pi: ExtensionAPI) {
4386
6188
  toolsBeforePlanMode = d.toolsBeforePlanMode;
4387
6189
  currentPlanFile = d.planFile;
4388
6190
  // restore role state (file config is source of truth for roles;
4389
- // session entry carries the latest activeRole)
6191
+ // session entry carries the latest activeRole) and the session's
6192
+ // own profile activation
4390
6193
  try {
4391
6194
  ensureRoleConfig((ctx as any)?.cwd);
6195
+ ensureProfilesConfig((ctx as any)?.cwd);
4392
6196
  const rd = (d as any).role;
4393
6197
  if (rd && typeof rd === "object") {
4394
6198
  if (typeof rd.activeRole === "string" || rd.activeRole === null)
4395
6199
  roleConfig.activeRole = rd.activeRole;
6200
+ // Profiles: an explicit pick restores exactly (name or none);
6201
+ // entries without an explicit choice (fresh / pre-profile /
6202
+ // default-seeded) re-derive workflow.defaultProfile below so
6203
+ // a changed default still applies on resume.
6204
+ profileExplicit = rd.profileExplicit === true;
6205
+ if (profileExplicit) {
6206
+ activeProfileName =
6207
+ typeof rd.activeProfile === "string"
6208
+ ? rd.activeProfile
6209
+ : null;
6210
+ if (
6211
+ activeProfileName &&
6212
+ !getProfile(profilesConfig, activeProfileName)
6213
+ ) {
6214
+ try {
6215
+ (ctx as any).ui?.notify?.(
6216
+ `Profiles: "${activeProfileName}" no longer exists — default roles flow active.`,
6217
+ "warning",
6218
+ );
6219
+ } catch (_e) {
6220
+ void _e;
6221
+ }
6222
+ activeProfileName = null;
6223
+ }
6224
+ } else {
6225
+ activeProfileName = null;
6226
+ }
4396
6227
  }
4397
6228
  } catch (_e) {
4398
6229
  void _e;
@@ -4474,6 +6305,39 @@ export default function workflowExtension(pi: ExtensionAPI) {
4474
6305
  } catch (_e) {
4475
6306
  void _e;
4476
6307
  }
6308
+ // Default-profile seeding: sessions without an explicit choice follow
6309
+ // workflow.defaultProfile, re-read at every start/resume so a changed
6310
+ // default applies. Sessions that picked explicitly (or explicitly chose
6311
+ // none) never see the default.
6312
+ if (!profileExplicit) {
6313
+ try {
6314
+ const def = readDefaultProfileSetting(
6315
+ (ctx as any).cwd ?? process.cwd(),
6316
+ getAgentDir(),
6317
+ ).value;
6318
+ if (def) {
6319
+ ensureProfilesConfig((ctx as any)?.cwd);
6320
+ const p = getProfile(profilesConfig, def);
6321
+ if (p) {
6322
+ if (activeProfileName !== p.name) {
6323
+ activeProfileName = p.name;
6324
+ invalidateEffectiveRoles();
6325
+ }
6326
+ } else {
6327
+ try {
6328
+ (ctx as any).ui?.notify?.(
6329
+ `Profiles: default profile "${def}" not found in profiles.json — skipping.`,
6330
+ "warning",
6331
+ );
6332
+ } catch (_e) {
6333
+ void _e;
6334
+ }
6335
+ }
6336
+ }
6337
+ } catch (_e) {
6338
+ void _e;
6339
+ }
6340
+ }
4477
6341
  // auto-enable plan mode if flag set at startup (support both --plan and --workflow-plan)
4478
6342
  const flagPlan =
4479
6343
  (pi as any).getFlagValue?.("plan") ??
@@ -4594,6 +6458,118 @@ export default function workflowExtension(pi: ExtensionAPI) {
4594
6458
  updateStatus(ctx as any);
4595
6459
  });
4596
6460
 
6461
+ // ── Commit policy gate (workflow.commitPolicy) ──────────────────
6462
+
6463
+ /**
6464
+ * Deterministic enforcement of the user's commit policy. Returns a block
6465
+ * result for `git add` / `git commit` / `git push` per the active mode, or
6466
+ * undefined when the command may proceed:
6467
+ * never → block; ask → confirmation overlay (headless: fail closed);
6468
+ * allow → automatic per-step commits while a plan todo list is active
6469
+ * (the extension owns commit timing — model-initiated git add/commit/push
6470
+ * 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).
6474
+ */
6475
+ async function commitPolicyGate(
6476
+ event: any,
6477
+ ctx: any,
6478
+ cwd: string,
6479
+ ): 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
+ const cmd = String(event?.input?.command ?? "");
6485
+ const hit = gitCommitPolicyHit(cmd);
6486
+ 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") {
6493
+ return undefined;
6494
+ }
6495
+
6496
+ if (policy === "never" || policy === "allow") {
6497
+ return {
6498
+ block: true,
6499
+ reason:
6500
+ policy === "never"
6501
+ ? `Commit policy is 'never' (workflow.commitPolicy): \`git ${hit}\` is blocked. ` +
6502
+ "Finish your steps and leave changes uncommitted for user review — the user commits manually or runs /commit. " +
6503
+ `Change the policy with /commit-policy allow.\nCommand: ${cmd}`
6504
+ : `Commit policy is 'allow' with an active plan: a commit is made AUTOMATICALLY after each completed step — do not run \`git ${hit}\` yourself. ` +
6505
+ "Finish the step, mark it done via workflow_todo, and continue; the extension commits it (exactly one commit per step). " +
6506
+ "/commit always works for user-invoked commits.\nCommand: " +
6507
+ cmd,
6508
+ };
6509
+ }
6510
+ // policy === "ask" → confirmation overlay. Headless has no one to ask →
6511
+ // fail closed rather than commit unbidden.
6512
+ const ui = ctx?.ui;
6513
+ if (!ui || !ctx.hasUI) {
6514
+ return {
6515
+ block: true,
6516
+ reason:
6517
+ `Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
6518
+ "but no interactive UI is available. Leave the change uncommitted and let the user decide.",
6519
+ };
6520
+ }
6521
+ let sel: unknown;
6522
+ try {
6523
+ sel = await ui.select(
6524
+ `Commit policy 'ask': allow \`git ${hit}\`?`,
6525
+ ["Allow once", "Deny"],
6526
+ );
6527
+ } catch {
6528
+ sel = undefined;
6529
+ }
6530
+ if (String(sel ?? "").startsWith("Allow")) return undefined;
6531
+ return {
6532
+ block: true,
6533
+ reason:
6534
+ `Commit policy 'ask': the user declined \`git ${hit}\`. Do not retry — ` +
6535
+ "leave the changes uncommitted for the user.",
6536
+ };
6537
+ }
6538
+
6539
+ /**
6540
+ * Policy-aware commit directive appended to the plan-execute handoff
6541
+ * message. Empty string when the policy cannot be read (a config hiccup
6542
+ * never blocks the handoff itself).
6543
+ */
6544
+ function commitDirectiveLine(ctx: any): string {
6545
+ try {
6546
+ const cwd = ctx?.cwd ?? process.cwd();
6547
+ const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
6548
+ switch (mode) {
6549
+ case "never":
6550
+ return (
6551
+ "\n\nCommit policy is 'never' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push`. " +
6552
+ "Complete all steps and leave every change uncommitted for user review — the user commits manually or via /commit."
6553
+ );
6554
+ case "ask":
6555
+ return (
6556
+ "\n\nCommit policy is 'ask' (workflow.commitPolicy): you may commit after each completed step " +
6557
+ "(`git add` + `git commit` with a concise conventional message); each `git commit`/`git push` will prompt the user for approval."
6558
+ );
6559
+ case "allow":
6560
+ return (
6561
+ "\n\nCommit policy is 'allow' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push` yourself — " +
6562
+ "after each completed step the extension automatically makes exactly ONE commit containing the changes so far. " +
6563
+ "Just finish a step, mark it done via workflow_todo, and move to the next step."
6564
+ );
6565
+ default:
6566
+ return "";
6567
+ }
6568
+ } catch {
6569
+ return "";
6570
+ }
6571
+ }
6572
+
4597
6573
  // Block edits/writes + bash gating
4598
6574
  pi.on("tool_call", async (event, ctx) => {
4599
6575
  const cwd = (ctx as ExtensionContext).cwd;
@@ -4652,6 +6628,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
4652
6628
  } as any;
4653
6629
  }
4654
6630
  }
6631
+ // ── Commit policy gate ── Independent of the role gate: it also governs
6632
+ // the unrestricted builder role — that is the point (user-controlled
6633
+ // commit behavior). Plan mode is excluded: planner gating already blocks
6634
+ // every mutating git command. Covers bash and powershell so a powershell
6635
+ // wrapper cannot bypass the policy.
6636
+ if (
6637
+ (event.toolName === "bash" || event.toolName === "powershell") &&
6638
+ workflowMode !== "plan"
6639
+ ) {
6640
+ const policyBlock = await commitPolicyGate(event, ctx, cwd);
6641
+ if (policyBlock) return policyBlock as any;
6642
+ }
4655
6643
  if (
4656
6644
  (event.toolName === "edit" || event.toolName === "write") &&
4657
6645
  workflowMode === "plan"
@@ -4735,6 +6723,104 @@ export default function workflowExtension(pi: ExtensionAPI) {
4735
6723
  });
4736
6724
 
4737
6725
  // ── Checkpoint lifecycle: track leaf entryId and prompt header ─
6726
+ // ── Deterministic per-step commit executor (allow mode) ──────────
6727
+
6728
+ /** Mark pre-existing done steps (session resume, re-executed plan) as already accounted for. Called once per session. */
6729
+ function seedCommittedStepRefs(): void {
6730
+ if (stepCommitsSeeded) return;
6731
+ stepCommitsSeeded = true;
6732
+ for (const t of todoItems) {
6733
+ if (!isDone(t) || itemStatus(t) === "cancelled") continue;
6734
+ committedStepRefs.add(todoRef(t));
6735
+ }
6736
+ }
6737
+
6738
+ /** Done steps that have not been committed yet (in canonical order). */
6739
+ function uncommittedDoneSteps(): StepCommitCandidate[] {
6740
+ const out: StepCommitCandidate[] = [];
6741
+ for (const t of todoItems) {
6742
+ if (!isDone(t) || itemStatus(t) === "cancelled") continue;
6743
+ const ref = todoRef(t);
6744
+ if (committedStepRefs.has(ref)) continue;
6745
+ out.push({ ref, step: t.step, label: t.label, text: t.text });
6746
+ }
6747
+ return out;
6748
+ }
6749
+
6750
+ /**
6751
+ * Make the automatic per-step commit when the policy calls for it.
6752
+ * Deterministic: driven by the step-completion signal (workflow_todo
6753
+ * tool_result / [DONE:ref] tags), never by model whimsy; the committed
6754
+ * ref set makes it exactly-once per step. Never throws — a commit hiccup
6755
+ * must not break the run.
6756
+ */
6757
+ async function autoCommitCompletedSteps(
6758
+ ctx: any,
6759
+ trigger: string,
6760
+ ): Promise<void> {
6761
+ try {
6762
+ const cwd = ctx?.cwd ?? process.cwd();
6763
+ if (!isGitRepoSync(cwd)) return;
6764
+ seedCommittedStepRefs();
6765
+ const completed = uncommittedDoneSteps();
6766
+ const dirty = await isDirty(pi as any, cwd);
6767
+ const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
6768
+ const decision = stepCommitDecision({
6769
+ mode,
6770
+ hasTodos: todoItems.length > 0,
6771
+ dirty,
6772
+ isInRepo: true,
6773
+ completed,
6774
+ });
6775
+ if (!decision.commit) return;
6776
+ const addRes = await piExecGit(["add", "-A"], cwd);
6777
+ if (addRes.exitCode !== 0) {
6778
+ try {
6779
+ ctx?.ui?.notify?.(
6780
+ `Per-step commit failed (git add): ${addRes.stderr.trim()}`,
6781
+ "error",
6782
+ );
6783
+ } catch (_e) {
6784
+ void _e;
6785
+ }
6786
+ return;
6787
+ }
6788
+ const commitRes = await piExecGit(["commit", "-m", decision.message], cwd);
6789
+ if (commitRes.exitCode !== 0) {
6790
+ try {
6791
+ ctx?.ui?.notify?.(
6792
+ `Per-step commit failed: ${commitRes.stderr.trim()}`,
6793
+ "error",
6794
+ );
6795
+ } catch (_e) {
6796
+ void _e;
6797
+ }
6798
+ return;
6799
+ }
6800
+ for (const ref of decision.refs) committedStepRefs.add(ref);
6801
+ try {
6802
+ ctx?.ui?.notify?.(
6803
+ `Committed ${decision.refs.join(", ")} — ${decision.message}`,
6804
+ "info",
6805
+ );
6806
+ } catch (_e) {
6807
+ void _e;
6808
+ }
6809
+ try {
6810
+ pi.appendEntry("workflow-step-commit", {
6811
+ refs: decision.refs,
6812
+ message: decision.message,
6813
+ trigger,
6814
+ at: Date.now(),
6815
+ });
6816
+ } catch (_e) {
6817
+ void _e;
6818
+ }
6819
+ } catch {
6820
+ // never break the agent run on a commit hiccup
6821
+ }
6822
+ }
6823
+
4738
6824
  pi.on("tool_result", async (_event, ctx) => {
4739
6825
  try {
4740
6826
  const leaf =
@@ -4746,8 +6832,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
4746
6832
  }
4747
6833
  });
4748
6834
 
6835
+ // Per-step commit trigger: the runner AWAITS tool_result handlers, so the
6836
+ // commit lands after the step is marked done and BEFORE the model starts
6837
+ // the next step — exactly one commit per step, at the moment of completion.
6838
+ pi.on("tool_result", async (event, ctx) => {
6839
+ try {
6840
+ const ev = event as any;
6841
+ if (ev?.toolName !== "workflow_todo") return;
6842
+ const action = String(ev?.input?.action ?? ev?.details?.action ?? "");
6843
+ if (!action || action === "list" || action === "clear") return;
6844
+ await autoCommitCompletedSteps(ctx, `workflow_todo:${action}`);
6845
+ } catch (_e) {
6846
+ void _e;
6847
+ }
6848
+ });
6849
+
4749
6850
  pi.on("turn_start", async (_event, ctx) => {
4750
6851
  footerInjectedThisTurn = false;
6852
+ // Seed the per-step commit ledger once per session: steps already done
6853
+ // (restored sessions, re-executed plans) never trigger a spurious commit.
6854
+ try {
6855
+ seedCommittedStepRefs();
6856
+ } catch (_e) {
6857
+ void _e;
6858
+ }
4751
6859
  if (!currentEntryId) return;
4752
6860
  const cwd = (ctx as any).cwd as string;
4753
6861
  try {
@@ -4778,6 +6886,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
4778
6886
  });
4779
6887
 
4780
6888
  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;
4781
6892
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
4782
6893
  });
4783
6894
 
@@ -4956,10 +7067,24 @@ export default function workflowExtension(pi: ExtensionAPI) {
4956
7067
 
4957
7068
  // Track progress (all modes, whenever todos exist)
4958
7069
  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;
4959
7074
  if (todoItems.length === 0) return;
4960
7075
  if (!isAssistantMessage(event.message as AgentMessage)) return;
4961
7076
  const text = getTextContent(event.message as AssistantMessage);
4962
7077
  const result = markCompletedRefs(text, todoItems);
7078
+ // Fallback per-step commit for steps completed via [DONE:ref] tags in the
7079
+ // response text (the workflow_todo path already committed at its
7080
+ // tool_result; the committed-ref set makes this a no-op then).
7081
+ if (result.marked > 0) {
7082
+ try {
7083
+ await autoCommitCompletedSteps(ctx, "turn_end:done-tags");
7084
+ } catch (_e) {
7085
+ void _e;
7086
+ }
7087
+ }
4963
7088
  if (result.unknown.length > 0 || result.ambiguous.length > 0) {
4964
7089
  lastRefMisses = { refs: [...result.unknown, ...result.ambiguous] };
4965
7090
  try {
@@ -5516,7 +7641,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5516
7641
  const list = formatTodoNumberedList(todoItems);
5517
7642
  const content = plannotatorActive
5518
7643
  ? `**Plan ready** (${todoItems.length} steps)\\n\\nTo review in browser, ensure SSH port forwarding is active:\\n\`\`\`\\nLocalForward 9999 localhost:9999\\n\`\`\`\\nThen set \`PLANNOTATOR_REMOTE=1\` and \`PLANNOTATOR_PORT=9999\`.\\n\\nOr type "Execute the plan" to build directly.\\n\\nRemaining:\\n${list}`
5519
- : `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine.`;
7644
+ : `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine.${commitDirectiveLine(ctx)}`;
5520
7645
  (pi as any).sendMessage?.(
5521
7646
  {
5522
7647
  customType: "workflow-handoff-fallback",
@@ -5932,7 +8057,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5932
8057
  display: true,
5933
8058
  };
5934
8059
  const firstTodo = todoItems[0];
5935
- const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
8060
+ const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
5936
8061
  (pi as any).sendMessage?.(planTodoListMessage, {
5937
8062
  deliverAs: "followUp",
5938
8063
  });