@dieulc/workflow 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -45,7 +45,6 @@ import {
45
45
  import { Type } from "typebox";
46
46
  import { StringEnum } from "@earendil-works/pi-ai";
47
47
  import {
48
- isSafeCommand,
49
48
  extractTodoItems,
50
49
  extractPlanStepsFromMarkdown,
51
50
  markCompletedRefs,
@@ -73,10 +72,21 @@ import {
73
72
  buildTodoFooter,
74
73
  formatAmbiguity,
75
74
  shouldRemind,
75
+ pruneContextEntriesForReview,
76
+ stripReviewAppendix,
77
+ REVIEW_UNRESOLVED_HEADING,
76
78
  type TodoStatus,
77
79
  type TodoItem,
78
80
  type TodoUpdateEntry,
79
81
  } from "./utils.ts";
82
+ import {
83
+ WORKFLOW_ROLE_ENV,
84
+ explainCommandRefusal,
85
+ policyForRole,
86
+ resolveSessionRole,
87
+ subagentEnv,
88
+ type WorkflowRole,
89
+ } from "./permissions.ts";
80
90
  import {
81
91
  isGitRepo,
82
92
  isGitRepoSync,
@@ -88,7 +98,8 @@ import {
88
98
  } from "./checkpoint.ts";
89
99
  import {
90
100
  defaultRoleConfig,
91
- loadRoleConfig,
101
+ loadRoleConfigDetailed,
102
+ rolesFingerprint,
92
103
  saveRoleConfig,
93
104
  getRole,
94
105
  formatRolesForDisplay,
@@ -96,12 +107,40 @@ import {
96
107
  parseModelRef,
97
108
  isValidThinkingLevel,
98
109
  roleIcon,
110
+ BUILT_IN_ROLES,
111
+ REVIEW_ROLE_NAME,
99
112
  VALID_THINKING_LEVELS,
100
113
  type RoleConfig,
101
114
  type Role,
102
115
  type RoleModel,
103
116
  type ThinkingLevel,
104
117
  } from "./roles.ts";
118
+ import {
119
+ applySubmittedPlan,
120
+ formatReviewStatusLines,
121
+ formatReviewTimeoutLabel,
122
+ parseReviewTimeoutArg,
123
+ planHash,
124
+ readReviewModeConfig,
125
+ resolveReviewPrompt,
126
+ reviewStateSummary,
127
+ shouldRouteInputToReviewer,
128
+ writeReviewModeConfig,
129
+ writeReviewPrompt,
130
+ type ReviewFinding,
131
+ type ReviewState,
132
+ type ReviewTranscriptOp,
133
+ } from "./review.ts";
134
+ import { createReviewRuntime, type ReviewRuntime } from "./review-runtime.ts";
135
+ import {
136
+ createReviewBandGate,
137
+ createReviewLineBuffer,
138
+ registerReviewEntryRenderers,
139
+ REVIEW_MODE_ENTRY_TYPE,
140
+ REVIEW_TRANSCRIPT_ENTRY_TYPE,
141
+ type ReviewLineBuffer,
142
+ type ReviewModeBandData,
143
+ } from "./review-ui.ts";
105
144
 
106
145
  // ── Plannotator Bridge ───────────────────────────────────────────────
107
146
  const PLANNOTATOR_REQUEST = "plannotator:request" as const;
@@ -139,6 +178,8 @@ interface AgentConfig {
139
178
  thinking?: string;
140
179
  systemPrompt: string;
141
180
  filePath: string;
181
+ /** Workflow role the spawned child is gated as (e.g. "explorer"). */
182
+ role?: WorkflowRole;
142
183
  }
143
184
  function discoverAgents(cwd: string): AgentConfig[] {
144
185
  const agents: AgentConfig[] = [];
@@ -241,6 +282,9 @@ async function runSingleAgent(
241
282
  cwd,
242
283
  shell: false,
243
284
  stdio: ["ignore", "pipe", "pipe"],
285
+ // Carry the role into the child so its workflow extension gates bash
286
+ // with the same policy the parent applies (see permissions.ts).
287
+ env: subagentEnv(process.env, agent.role),
244
288
  },
245
289
  );
246
290
  // Track for cleanup on session_shutdown — prevents orphaned subagents
@@ -317,6 +361,9 @@ function getFinalOutput(messages: any[]): string {
317
361
  // ── Main Extension ───────────────────────────────────────────────────
318
362
 
319
363
  export default function workflowExtension(pi: ExtensionAPI) {
364
+ // Review Mode streams the reviewer's output into the main transcript as
365
+ // display-only entries. Registering once here keeps /reload idempotent.
366
+ registerReviewEntryRenderers(pi);
320
367
  type WorkflowMode = "plan" | "build";
321
368
  let workflowMode: WorkflowMode | null = null;
322
369
  // overlay guard: true while any ctx.ui.custom overlay is active (questionnaire/rewind/decision gate)
@@ -361,13 +408,45 @@ export default function workflowExtension(pi: ExtensionAPI) {
361
408
  // ── Model roles state ──────────────────────────────────────────
362
409
  let roleConfig: RoleConfig = defaultRoleConfig();
363
410
  let roleConfigLoadedFor: string | undefined;
411
+ let roleConfigLoadedFingerprint: string | undefined;
412
+ /** Reason the last load fell back to in-memory defaults (warn once). */
413
+ let roleConfigFallbackReason: string | undefined;
414
+ let roleConfigFallbackNotified = false;
415
+
416
+ /**
417
+ * The single roles accessor. Reloads when the working directory changes or
418
+ * either roles.json changes on disk (mtime/size), so a `/role` edit made in
419
+ * another window is picked up without a `/reload`. Non-destructive: a bad or
420
+ * missing file is never overwritten (see loadRoleConfigDetailed).
421
+ */
364
422
  function ensureRoleConfig(cwd?: string): RoleConfig {
365
- if (roleConfigLoadedFor === undefined) {
366
- roleConfig = loadRoleConfig(cwd);
367
- roleConfigLoadedFor = cwd ?? "";
423
+ const key = cwd ?? "";
424
+ const fingerprint = rolesFingerprint(key);
425
+ if (
426
+ roleConfigLoadedFor === key &&
427
+ roleConfigLoadedFingerprint === fingerprint
428
+ ) {
429
+ return roleConfig;
368
430
  }
431
+ const loaded = loadRoleConfigDetailed(key);
432
+ roleConfig = loaded.config;
433
+ roleConfigLoadedFor = key;
434
+ roleConfigLoadedFingerprint = fingerprint;
435
+ roleConfigFallbackReason = loaded.usedDefaults ? loaded.reason : undefined;
436
+ roleConfigFallbackNotified = false;
369
437
  return roleConfig;
370
438
  }
439
+
440
+ /** Surface a roles fallback once per load, when a ctx is available. */
441
+ function notifyRoleConfigFallback(ctx?: ExtensionContext): void {
442
+ if (!roleConfigFallbackReason || roleConfigFallbackNotified) return;
443
+ roleConfigFallbackNotified = true;
444
+ try {
445
+ ctx?.ui.notify(`Roles: ${roleConfigFallbackReason}`, "warning");
446
+ } catch (_e) {
447
+ void _e;
448
+ }
449
+ }
371
450
  function activeRoleLabel(): string {
372
451
  if (roleConfig.activeRole)
373
452
  return `${roleIcon(roleConfig.activeRole)} ${roleConfig.activeRole}`;
@@ -559,6 +638,673 @@ export default function workflowExtension(pi: ExtensionAPI) {
559
638
  }
560
639
  }
561
640
 
641
+ // ══════════════════════════════════════════════════════════════════
642
+ // Review Mode — an independent reviewer that audits Plan Mode
643
+ //
644
+ // Everything here is gated by `reviewEnabled()`. When Review Mode is off
645
+ // (the default) none of this runs and the plan/build flow is unchanged.
646
+ // ══════════════════════════════════════════════════════════════════
647
+
648
+ let reviewRuntime: ReviewRuntime | undefined;
649
+ let reviewState: ReviewState | undefined;
650
+ /** Hash of the plan revision already reviewed, so a re-emit does not re-review. */
651
+ let lastReviewedPlanHash: string | undefined;
652
+ /** 1-based round within the current user-feedback cycle. */
653
+ let reviewRound = 0;
654
+ /** Findings carried into the next round so the reviewer can verify fixes. */
655
+ let reviewPreviousFindings: ReviewFinding[] | undefined;
656
+
657
+ // ── Review Mode: main-transcript stream ──────────────────────────
658
+ /** Coalesces reviewer ops into transcript entries; created on first use. */
659
+ let reviewEntryBuffer: ReviewLineBuffer | undefined;
660
+ let reviewFlushTimer: ReturnType<typeof setTimeout> | undefined;
661
+ /** Lines already appended to the transcript (bounded so it cannot flood). */
662
+ let reviewEmittedLines = 0;
663
+ let reviewTruncationNoted = false;
664
+ /** Suppresses a band identical to the newest one (Tab cycles / re-runs). */
665
+ const reviewBandGate = createReviewBandGate();
666
+ const REVIEW_FLUSH_MS = 400;
667
+ const MAX_REVIEW_EMITTED_LINES = 600;
668
+
669
+ function reviewStream(): ReviewLineBuffer {
670
+ if (!reviewEntryBuffer) reviewEntryBuffer = createReviewLineBuffer();
671
+ return reviewEntryBuffer;
672
+ }
673
+
674
+ /** Reviewer model label for the mode band (best-effort). */
675
+ function reviewModelLabel(): string {
676
+ ensureRoleConfig(reviewCwd());
677
+ const r = getRole(roleConfig, REVIEW_ROLE_NAME);
678
+ return r?.model ? formatModelRef(r.model) : "unconfigured";
679
+ }
680
+
681
+ /** Append a colored Plan/Review/Review-done band to the main transcript. */
682
+ function emitReviewBand(data: ReviewModeBandData): void {
683
+ if (!reviewBandGate.accept(data)) return; // identical to the newest band
684
+ try {
685
+ pi.appendEntry(REVIEW_MODE_ENTRY_TYPE, { ...data, at: Date.now() });
686
+ } catch (_e) {
687
+ void _e;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Flush buffered reviewer lines into one display-only transcript entry.
693
+ * `force` finalizes a still-growing line (round end / teardown).
694
+ */
695
+ function flushReviewEntries(force = false): void {
696
+ const buffer = reviewEntryBuffer;
697
+ if (!buffer) return;
698
+ let lines = force ? buffer.flush() : buffer.takeComplete();
699
+ if (lines.length === 0) return;
700
+ if (reviewEmittedLines >= MAX_REVIEW_EMITTED_LINES) {
701
+ if (!reviewTruncationNoted) {
702
+ reviewTruncationNoted = true;
703
+ try {
704
+ pi.appendEntry(REVIEW_TRANSCRIPT_ENTRY_TYPE, {
705
+ at: Date.now(),
706
+ lines: [
707
+ {
708
+ kind: "notice",
709
+ text: "… review output truncated in the transcript — see /review-status for the full findings",
710
+ },
711
+ ],
712
+ });
713
+ } catch (_e) {
714
+ void _e;
715
+ }
716
+ }
717
+ buffer.clear();
718
+ return;
719
+ }
720
+ const room = MAX_REVIEW_EMITTED_LINES - reviewEmittedLines;
721
+ const capped = lines.slice(0, room);
722
+ reviewEmittedLines += capped.length;
723
+ try {
724
+ pi.appendEntry(REVIEW_TRANSCRIPT_ENTRY_TYPE, {
725
+ at: Date.now(),
726
+ lines: capped,
727
+ });
728
+ } catch (_e) {
729
+ void _e;
730
+ }
731
+ }
732
+
733
+ /** Throttle flushes so a token stream cannot spam the transcript. */
734
+ function scheduleReviewFlush(): void {
735
+ if (reviewFlushTimer !== undefined) return;
736
+ reviewFlushTimer = setTimeout(() => {
737
+ reviewFlushTimer = undefined;
738
+ flushReviewEntries();
739
+ }, REVIEW_FLUSH_MS);
740
+ // Do not keep the process alive just for a display flush.
741
+ (reviewFlushTimer as { unref?: () => void })?.unref?.();
742
+ }
743
+
744
+ /** Buffer reviewer ops and schedule a throttled flush. */
745
+ function pushReviewOps(ops: ReviewTranscriptOp[]): void {
746
+ const buffer = reviewStream();
747
+ for (const op of ops) buffer.push(op);
748
+ scheduleReviewFlush();
749
+ }
750
+
751
+ /** Append a synthetic line (e.g. a note the user routed to the reviewer). */
752
+ function pushReviewNote(kind: "user" | "notice", text: string): void {
753
+ reviewStream().pushLine(kind, text);
754
+ flushReviewEntries(true);
755
+ }
756
+
757
+ /** Clear stream state (teardown / mode switch). */
758
+ function resetReviewStream(): void {
759
+ if (reviewFlushTimer !== undefined) {
760
+ clearTimeout(reviewFlushTimer);
761
+ reviewFlushTimer = undefined;
762
+ }
763
+ reviewEntryBuffer = undefined;
764
+ reviewEmittedLines = 0;
765
+ reviewTruncationNoted = false;
766
+ }
767
+
768
+ const REVIEW_STATUS_WIDGET = "workflow-review-status";
769
+
770
+ function reviewAgentDir(): string {
771
+ try {
772
+ return getAgentDir();
773
+ } catch {
774
+ return path.join(os.homedir(), ".pi", "agent");
775
+ }
776
+ }
777
+
778
+ function reviewCwd(): string {
779
+ return currentSessionCtx?.cwd ?? process.cwd();
780
+ }
781
+
782
+ function reviewConfigNow() {
783
+ try {
784
+ return readReviewModeConfig(reviewCwd(), reviewAgentDir());
785
+ } catch {
786
+ return undefined;
787
+ }
788
+ }
789
+
790
+ /** The single gate every Review Mode code path checks. */
791
+ function reviewEnabled(): boolean {
792
+ try {
793
+ return reviewConfigNow()?.config.enabled === true;
794
+ } catch {
795
+ return false;
796
+ }
797
+ }
798
+
799
+ function notifyReview(
800
+ message: string,
801
+ level: "info" | "warning" | "error" = "info",
802
+ ): void {
803
+ try {
804
+ currentSessionCtx?.ui.notify(`Review Mode: ${message}`, level);
805
+ } catch (_e) {
806
+ void _e;
807
+ }
808
+ }
809
+
810
+ /** Status strip: a compact line above the editor while a review runs. */
811
+ function updateReviewStatus(ctx: ExtensionContext): void {
812
+ try {
813
+ if (!reviewEnabled() || !reviewState) {
814
+ ctx.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
815
+ return;
816
+ }
817
+ const summary = reviewStateSummary(reviewState);
818
+ ctx.ui.setWidget(REVIEW_STATUS_WIDGET, (_tui: any, theme: any) => ({
819
+ render: (w: number) => [
820
+ truncateToWidth(
821
+ theme.fg("muted", summary),
822
+ Math.max(10, Number(w) || 40),
823
+ "…",
824
+ ),
825
+ ],
826
+ invalidate: () => {},
827
+ dispose: () => {},
828
+ }));
829
+ } catch (_e) {
830
+ void _e;
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Read-only reconnaissance for the reviewer, reusing the existing explorer
836
+ * subagent machinery (same spawn path as the `explore` tool) but pinned to
837
+ * the explorer role's model and a strictly read-only tool set.
838
+ */
839
+ function runReviewExplore(
840
+ task: string,
841
+ signal?: AbortSignal,
842
+ ): Promise<string> {
843
+ return (async () => {
844
+ const cwd = reviewCwd();
845
+ const agents = discoverAgents(cwd);
846
+ const base = agents.find((a) => a.name === "scout") ?? agents[0];
847
+ const explorerRole = getRole(roleConfig, "explorer");
848
+ const m = explorerRole?.model;
849
+ const agent: AgentConfig = {
850
+ name: base?.name ?? "review-scout",
851
+ description: base?.description ?? "Read-only reconnaissance",
852
+ tools: ["read", "grep", "find", "ls"],
853
+ systemPrompt:
854
+ base?.systemPrompt ??
855
+ "You are a READ-ONLY reconnaissance agent. Answer the question with file:line evidence. Never write files. Be concise.",
856
+ filePath: base?.filePath ?? "",
857
+ role: "explorer",
858
+ };
859
+ if (m) agent.model = `${m.provider}/${m.id}`;
860
+ if (m?.thinking) agent.thinking = m.thinking;
861
+
862
+ const res = await runSingleAgent(cwd, agent, task, signal);
863
+ const out = getFinalOutput(res.messages);
864
+ if (!out && res.exitCode !== 0) {
865
+ throw new Error(
866
+ res.stderr.slice(0, 400) || `explore exited ${res.exitCode}`,
867
+ );
868
+ }
869
+ return out || "(no findings)";
870
+ })();
871
+ }
872
+
873
+ /** Lazily construct the runtime so nothing is allocated when Review is off. */
874
+ function ensureReviewRuntime(): ReviewRuntime {
875
+ if (reviewRuntime) return reviewRuntime;
876
+ const cwd = reviewCwd();
877
+ const agentDir = reviewAgentDir();
878
+ ensureRoleConfig(cwd);
879
+ reviewRuntime = createReviewRuntime({
880
+ cwd,
881
+ agentDir,
882
+ config: () => reviewConfigNow()?.config,
883
+ reviewerModel: () => {
884
+ ensureRoleConfig(cwd);
885
+ const reviewer = getRole(roleConfig, REVIEW_ROLE_NAME);
886
+ if (reviewer?.model) return reviewer.model;
887
+ // Graceful degradation: fall back to the planner model rather than
888
+ // silently running no review at all.
889
+ const planner = getRole(roleConfig, "planner");
890
+ return planner?.model;
891
+ },
892
+ findModel: (provider: string, id: string) =>
893
+ (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
894
+ planPath: () => currentPlanFile,
895
+ readPlan: () => {
896
+ const p = currentPlanFile;
897
+ if (!p) return undefined;
898
+ try {
899
+ const abs = path.isAbsolute(p) ? p : path.join(cwd, p);
900
+ return fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : undefined;
901
+ } catch (_e) {
902
+ void _e;
903
+ return undefined;
904
+ }
905
+ },
906
+ contextEntries: () => {
907
+ try {
908
+ const branch =
909
+ (currentSessionCtx?.sessionManager as any)?.getBranch?.() ?? [];
910
+ return pruneContextEntriesForReview(branch);
911
+ } catch (_e) {
912
+ void _e;
913
+ return [];
914
+ }
915
+ },
916
+ runExplore: (task: string, signal?: AbortSignal) =>
917
+ runReviewExplore(task, signal),
918
+ writePlan: (input: any) =>
919
+ applySubmittedPlan({
920
+ ...input,
921
+ cwd,
922
+ pi: pi as any,
923
+ notify: (m: string, l?: "info" | "warning" | "error") =>
924
+ notifyReview(m, l ?? "info"),
925
+ }),
926
+ log: (m: string, l?: "info" | "warning" | "error") =>
927
+ notifyReview(m, l ?? "info"),
928
+ notify: (m: string, l?: "info" | "warning" | "error") =>
929
+ notifyReview(m, l ?? "info"),
930
+ promptText: () => resolveReviewPrompt(cwd, agentDir).text,
931
+ onState: (s: ReviewState | undefined) => {
932
+ reviewState = s;
933
+ if (currentSessionCtx) updateReviewStatus(currentSessionCtx);
934
+ },
935
+ onTranscriptOps: (ops) => pushReviewOps(ops),
936
+ });
937
+ return reviewRuntime;
938
+ }
939
+
940
+ /**
941
+ * Run one Review step over the plan text. Returns the plan to hand to the
942
+ * user: the reviewer's rewrite on success, or the author's plan unchanged on
943
+ * any failure (config.fallbackOnError = "skip" — a broken reviewer must never
944
+ * block the plan).
945
+ */
946
+ async function runReviewStep(
947
+ planText: string,
948
+ ctx: ExtensionContext,
949
+ ): Promise<string> {
950
+ const cfg = reviewConfigNow()?.config;
951
+ if (!cfg?.enabled) return planText;
952
+
953
+ // Already reviewed this exact revision — do not re-review on re-emit.
954
+ if (planHash(planText) === lastReviewedPlanHash) return planText;
955
+
956
+ reviewRound += 1;
957
+ if (reviewRound > cfg.rounds) {
958
+ // Round cap reached: hand over with the outstanding findings visible.
959
+ const unresolved = (reviewState?.findings ?? []).filter(
960
+ (f) => f.disposition !== "rejected",
961
+ );
962
+ emitReviewBand({
963
+ mode: "review-done",
964
+ reason: `round limit (${cfg.rounds}) reached`,
965
+ });
966
+ if (unresolved.length === 0) return planText;
967
+ const appendix = [
968
+ "",
969
+ "---",
970
+ "",
971
+ REVIEW_UNRESOLVED_HEADING,
972
+ "",
973
+ `Review Mode reached its round limit (${cfg.rounds}) with these items still open:`,
974
+ "",
975
+ ...unresolved.map(
976
+ (f) =>
977
+ `- (severity ${f.severity}) ${f.summary}${
978
+ f.rationale ? ` — ${f.rationale}` : ""
979
+ }`,
980
+ ),
981
+ "",
982
+ ].join("\n");
983
+ return `${stripReviewAppendix(planText).replace(/\s+$/, "")}\n${appendix}`;
984
+ }
985
+
986
+ emitReviewBand({
987
+ mode: "review",
988
+ round: reviewRound,
989
+ modelLabel: reviewModelLabel(),
990
+ });
991
+
992
+ try {
993
+ const rt = ensureReviewRuntime();
994
+ const res = await rt.runRound({
995
+ planText,
996
+ planPath: currentPlanFile ?? "",
997
+ round: reviewRound,
998
+ previousFindings: reviewPreviousFindings,
999
+ });
1000
+ flushReviewEntries(true);
1001
+ reviewState = res.state;
1002
+ updateReviewStatus(ctx);
1003
+ if (res.ok) {
1004
+ lastReviewedPlanHash = res.planHash;
1005
+ const keep = res.state.findings.filter(
1006
+ (f) => f.disposition !== "rejected",
1007
+ );
1008
+ reviewPreviousFindings = keep.length > 0 ? keep : undefined;
1009
+ emitReviewBand({ mode: "review-done", verdict: res.state.verdict });
1010
+ return res.planText;
1011
+ }
1012
+ notifyReview(
1013
+ `review skipped (${res.reason ?? "unavailable"}) — handing over the plan unreviewed`,
1014
+ "warning",
1015
+ );
1016
+ emitReviewBand({
1017
+ mode: "review-done",
1018
+ reason: res.reason ?? "unavailable",
1019
+ });
1020
+ return planText;
1021
+ } catch (e: any) {
1022
+ notifyReview(
1023
+ `review failed (${String(e?.message ?? e)}) — handing over the plan unreviewed`,
1024
+ "warning",
1025
+ );
1026
+ emitReviewBand({
1027
+ mode: "review-done",
1028
+ reason: `failed: ${String(e?.message ?? e)}`,
1029
+ });
1030
+ return planText;
1031
+ }
1032
+ }
1033
+
1034
+ /** Stop the reviewer and clear all Review Mode state. Never throws. */
1035
+ async function teardownReview(): Promise<void> {
1036
+ try {
1037
+ await reviewRuntime?.teardown();
1038
+ } catch (_e) {
1039
+ void _e;
1040
+ }
1041
+ reviewRuntime = undefined;
1042
+ reviewState = undefined;
1043
+ lastReviewedPlanHash = undefined;
1044
+ reviewRound = 0;
1045
+ reviewPreviousFindings = undefined;
1046
+ flushReviewEntries(true);
1047
+ resetReviewStream();
1048
+ try {
1049
+ currentSessionCtx?.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
1050
+ } catch (_e) {
1051
+ void _e;
1052
+ }
1053
+ }
1054
+
1055
+ pi.registerCommand("review-mode", {
1056
+ description:
1057
+ "Toggle Review Mode (independent reviewer that audits and rewrites plans): /review-mode on|off|status|timeout",
1058
+ handler: async (args, ctx) => {
1059
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
1060
+ const agentDir = reviewAgentDir();
1061
+ // Self-sufficient: never render a role that was never loaded from disk.
1062
+ ensureRoleConfig(cwd);
1063
+ notifyRoleConfigFallback(ctx as ExtensionContext);
1064
+ const tokens = String(args || "")
1065
+ .trim()
1066
+ .split(/\s+/)
1067
+ .filter(Boolean);
1068
+ const sub = (tokens[0] || "").toLowerCase();
1069
+ // Scope is any explicit `global`/`project` token after the subcommand.
1070
+ const scopeFor = (
1071
+ fallback: "global" | "project",
1072
+ ): "global" | "project" => {
1073
+ for (const t of tokens.slice(1)) {
1074
+ if (t === "global" || t === "project") return t;
1075
+ }
1076
+ return fallback;
1077
+ };
1078
+
1079
+ if (!sub || sub === "status") {
1080
+ const resolved = readReviewModeConfig(cwd, agentDir);
1081
+ const c = resolved.config;
1082
+ const model = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1083
+ ctx.ui.notify(
1084
+ formatReviewStatusLines({
1085
+ enabled: c.enabled,
1086
+ config: c,
1087
+ timeoutSource: resolved.sources.timeout,
1088
+ reviewer: model
1089
+ ? {
1090
+ provider: model.provider,
1091
+ id: model.id,
1092
+ thinking: model.thinking,
1093
+ }
1094
+ : undefined,
1095
+ promptSource: resolveReviewPrompt(cwd, agentDir).source,
1096
+ sources: {
1097
+ global: resolved.sources.global,
1098
+ project: resolved.sources.project,
1099
+ },
1100
+ runtime: reviewRuntime?.isRoundActive()
1101
+ ? "reviewing"
1102
+ : reviewRuntime?.isAlive()
1103
+ ? "session active"
1104
+ : "not started",
1105
+ }).join("\n"),
1106
+ "info",
1107
+ );
1108
+ return;
1109
+ }
1110
+
1111
+ if (sub === "timeout") {
1112
+ const scope = scopeFor("global");
1113
+ const rawValue = tokens
1114
+ .slice(1)
1115
+ .find((t) => t !== "global" && t !== "project");
1116
+ if (rawValue === undefined) {
1117
+ const resolved = readReviewModeConfig(cwd, agentDir);
1118
+ ctx.ui.notify(
1119
+ [
1120
+ `Review Mode timeout: ${formatReviewTimeoutLabel(
1121
+ resolved.config.timeoutMs,
1122
+ resolved.sources.timeout,
1123
+ )}`,
1124
+ "Usage: /review-mode timeout <seconds|unlimited> [global|project]",
1125
+ ].join("\n"),
1126
+ "info",
1127
+ );
1128
+ return;
1129
+ }
1130
+ const parsed = parseReviewTimeoutArg(rawValue);
1131
+ if (!parsed.ok) {
1132
+ ctx.ui.notify(`Invalid timeout: ${parsed.error}`, "warning");
1133
+ return;
1134
+ }
1135
+ const written = writeReviewModeConfig(scope, cwd, agentDir, {
1136
+ timeoutMs: parsed.timeoutMs,
1137
+ });
1138
+ if (!written.ok) {
1139
+ ctx.ui.notify(
1140
+ `Could not write review settings: ${written.error ?? "unknown error"}`,
1141
+ "error",
1142
+ );
1143
+ return;
1144
+ }
1145
+ ctx.ui.notify(
1146
+ `Review Mode timeout → ${formatReviewTimeoutLabel(
1147
+ parsed.timeoutMs,
1148
+ scope,
1149
+ )} (${scope})\nSettings: ${written.path}`,
1150
+ "info",
1151
+ );
1152
+ return;
1153
+ }
1154
+
1155
+ if (sub !== "on" && sub !== "off") {
1156
+ ctx.ui.notify(
1157
+ "Usage: /review-mode on|off [global|project] | status | timeout <seconds|unlimited> [global|project]",
1158
+ "info",
1159
+ );
1160
+ return;
1161
+ }
1162
+
1163
+ const scope = scopeFor("global");
1164
+ const enable = sub === "on";
1165
+
1166
+ const res = writeReviewModeConfig(scope, cwd, agentDir, {
1167
+ enabled: enable,
1168
+ });
1169
+ if (!res.ok) {
1170
+ ctx.ui.notify(
1171
+ `Could not write review settings: ${res.error ?? "unknown error"}`,
1172
+ "error",
1173
+ );
1174
+ return;
1175
+ }
1176
+
1177
+ if (enable) {
1178
+ ctx.ui.notify(
1179
+ `Review Mode ENABLED (${scope}) — after Plan Mode finishes, an independent reviewer audits the plan and hands it to Plannotator.\nSettings: ${res.path}`,
1180
+ "info",
1181
+ );
1182
+ updateReviewStatus(ctx as any);
1183
+ return;
1184
+ }
1185
+
1186
+ // Disabling: stop everything and restore the pre-Review behaviour.
1187
+ await teardownReview();
1188
+ ctx.ui.notify(
1189
+ `Review Mode disabled (${scope}) — plan/build flow is back to single-agent.\nSettings: ${res.path}`,
1190
+ "info",
1191
+ );
1192
+ },
1193
+ });
1194
+
1195
+ pi.registerCommand("review-prompt", {
1196
+ description:
1197
+ "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
1198
+ handler: async (args, ctx) => {
1199
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
1200
+ const agentDir = reviewAgentDir();
1201
+ const resolved = resolveReviewPrompt(cwd, agentDir);
1202
+ const scope: "global" | "project" =
1203
+ String(args || "")
1204
+ .trim()
1205
+ .toLowerCase() === "global"
1206
+ ? "global"
1207
+ : "project";
1208
+ if (!(ctx as any)?.hasUI) {
1209
+ ctx.ui.notify(
1210
+ `Reviewer guidance (${resolved.source}):\n\n${resolved.text}`,
1211
+ "info",
1212
+ );
1213
+ return;
1214
+ }
1215
+ const edited = await (ctx as any).ui.editor(
1216
+ `Reviewer guidance (${scope}) — plain markdown`,
1217
+ resolved.text,
1218
+ );
1219
+ if (edited === undefined || edited === null) {
1220
+ ctx.ui.notify("Review prompt unchanged.", "info");
1221
+ return;
1222
+ }
1223
+ const w = writeReviewPrompt(scope, String(edited), cwd, agentDir);
1224
+ ctx.ui.notify(
1225
+ w.ok
1226
+ ? `Reviewer guidance saved (${scope}): ${w.path}`
1227
+ : `Could not save reviewer guidance: ${w.error ?? "unknown error"}`,
1228
+ w.ok ? "info" : "error",
1229
+ );
1230
+ },
1231
+ });
1232
+
1233
+ pi.registerCommand("review-status", {
1234
+ description: "Show Review Mode state, findings and any error",
1235
+ handler: async (_args, ctx) => {
1236
+ const s = reviewState;
1237
+ if (!s) {
1238
+ ctx.ui.notify(
1239
+ reviewEnabled()
1240
+ ? "Review Mode is enabled but no review has run yet."
1241
+ : "Review Mode is disabled (/review-mode on to enable).",
1242
+ "info",
1243
+ );
1244
+ return;
1245
+ }
1246
+ const lines = [
1247
+ reviewStateSummary(s),
1248
+ ` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
1249
+ ` model : ${s.modelLabel}`,
1250
+ ` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
1251
+ ` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
1252
+ s.verdict ? ` verdict : ${s.verdict}` : "",
1253
+ s.error ? ` error : ${s.error}` : "",
1254
+ ...s.findings.map(
1255
+ (f) =>
1256
+ ` - [${f.disposition}] sev ${f.severity}/conf ${f.confidence} ${f.summary}`,
1257
+ ),
1258
+ ].filter(Boolean);
1259
+ ctx.ui.notify(lines.join("\n"), "info");
1260
+ },
1261
+ });
1262
+
1263
+ pi.registerCommand("review", {
1264
+ description:
1265
+ "Run one Review round against the current plan file now (works outside plan mode)",
1266
+ handler: async (_args, ctx) => {
1267
+ if (!reviewEnabled()) {
1268
+ ctx.ui.notify(
1269
+ "Review Mode is disabled — /review-mode on to enable.",
1270
+ "warning",
1271
+ );
1272
+ return;
1273
+ }
1274
+ const planPath = currentPlanFile;
1275
+ if (!planPath) {
1276
+ ctx.ui.notify(
1277
+ "No plan file yet — write one to .pi/plans/ first.",
1278
+ "warning",
1279
+ );
1280
+ return;
1281
+ }
1282
+ let text: string | undefined;
1283
+ try {
1284
+ const abs = path.isAbsolute(planPath)
1285
+ ? planPath
1286
+ : path.join((ctx as any).cwd ?? process.cwd(), planPath);
1287
+ text = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : undefined;
1288
+ } catch (_e) {
1289
+ void _e;
1290
+ }
1291
+ if (!text) {
1292
+ ctx.ui.notify(`Plan file not readable: ${planPath}`, "warning");
1293
+ return;
1294
+ }
1295
+ lastReviewedPlanHash = undefined; // force a fresh review
1296
+ reviewRound = 0;
1297
+ const rewritten = await runReviewStep(text, ctx as any);
1298
+ ctx.ui.notify(
1299
+ rewritten === text
1300
+ ? "Review finished — no usable rewrite (plan left unchanged)."
1301
+ : "Review finished — plan rewritten and saved.",
1302
+ "info",
1303
+ );
1304
+ },
1305
+ });
1306
+
1307
+
562
1308
  // ── Mode → role model alignment (plan=planner, build=builder) ──────
563
1309
  const MODE_ROLE_MAP: Record<WorkflowMode, string> = {
564
1310
  plan: "planner",
@@ -574,6 +1320,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
574
1320
  workflowMode: WorkflowMode;
575
1321
  } | null = null;
576
1322
 
1323
+ /**
1324
+ * Mode setters accept `quiet` so keyboard cycling (Tab / Ctrl+Alt+P) never
1325
+ * writes to the chat transcript — the footer status reports the mode instead.
1326
+ * Explicit commands call them without options.
1327
+ */
1328
+ interface ModeSwitchOptions {
1329
+ quiet?: boolean;
1330
+ }
1331
+
577
1332
  /** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
578
1333
  function resolveModeModel(
579
1334
  cfg: RoleConfig,
@@ -587,7 +1342,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
587
1342
  return { role, model: role.model };
588
1343
  }
589
1344
 
590
- async function applyModeModel(ctx: ExtensionContext): Promise<boolean> {
1345
+ async function applyModeModel(
1346
+ ctx: ExtensionContext,
1347
+ opts?: ModeSwitchOptions,
1348
+ ): Promise<boolean> {
591
1349
  if (!workflowMode) return false;
592
1350
  try {
593
1351
  ensureRoleConfig((ctx as any)?.cwd ?? ctx.cwd);
@@ -613,15 +1371,35 @@ export default function workflowExtension(pi: ExtensionAPI) {
613
1371
  }
614
1372
  const { role, model: m } = resolved;
615
1373
  let fromLabel = "";
1374
+ let current: any = null;
616
1375
  try {
617
- const cur =
1376
+ current =
618
1377
  (pi as any)?.getCurrentModel?.() ?? (ctx as any)?.model ?? null;
619
- if (cur?.provider && cur?.id) fromLabel = `${cur.provider}/${cur.id}`;
620
- else if ((pi as any)?.model?.provider)
621
- fromLabel = `${(pi as any).model.provider}/${(pi as any).model.id}`;
1378
+ if (current?.provider && current?.id) {
1379
+ fromLabel = `${current.provider}/${current.id}`;
1380
+ } else if ((pi as any)?.model?.provider) {
1381
+ current = (pi as any).model;
1382
+ fromLabel = `${current.provider}/${current.id}`;
1383
+ }
622
1384
  } catch (_e) {
623
1385
  void _e;
624
1386
  }
1387
+ // Already on the target model: no switch, no announcement. A changed
1388
+ // thinking level is still reconciled so the role's setting is not lost.
1389
+ if (
1390
+ current?.provider &&
1391
+ current.provider === m.provider &&
1392
+ current.id === m.id
1393
+ ) {
1394
+ try {
1395
+ if (m.thinking && isValidThinkingLevel(m.thinking)) {
1396
+ (pi as any).setThinkingLevel?.(m.thinking);
1397
+ }
1398
+ } catch (_e) {
1399
+ void _e;
1400
+ }
1401
+ return true;
1402
+ }
625
1403
  let found: any;
626
1404
  try {
627
1405
  const reg = (ctx as any)?.modelRegistry;
@@ -662,10 +1440,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
662
1440
  at: Date.now(),
663
1441
  workflowMode,
664
1442
  };
665
- ctx.ui.notify(
666
- `Workflow ${workflowMode}: model → ${m.provider}/${m.id} (${m.thinking})`,
667
- "info",
668
- );
1443
+ if (!opts?.quiet) {
1444
+ ctx.ui.notify(
1445
+ `Workflow ${workflowMode}: model → ${m.provider}/${m.id} (${m.thinking})`,
1446
+ "info",
1447
+ );
1448
+ }
669
1449
  return true;
670
1450
  }
671
1451
 
@@ -689,8 +1469,36 @@ export default function workflowExtension(pi: ExtensionAPI) {
689
1469
  }
690
1470
 
691
1471
  /** Restore the model that was active the last time Default mode was left. */
692
- async function restoreDefaultModel(ctx: ExtensionContext): Promise<void> {
1472
+ async function restoreDefaultModel(
1473
+ ctx: ExtensionContext,
1474
+ opts?: ModeSwitchOptions,
1475
+ ): Promise<void> {
693
1476
  if (!defaultModel) return;
1477
+ // No-op restore: the remembered model is already active.
1478
+ let current: any = null;
1479
+ try {
1480
+ current =
1481
+ (pi as any)?.getCurrentModel?.() ?? (ctx as any)?.model ?? null;
1482
+ } catch (_e) {
1483
+ void _e;
1484
+ }
1485
+ if (
1486
+ current?.provider &&
1487
+ current.provider === defaultModel.provider &&
1488
+ current.id === defaultModel.id
1489
+ ) {
1490
+ try {
1491
+ if (
1492
+ defaultModel.thinking &&
1493
+ isValidThinkingLevel(defaultModel.thinking)
1494
+ ) {
1495
+ (pi as any).setThinkingLevel?.(defaultModel.thinking);
1496
+ }
1497
+ } catch (_e) {
1498
+ void _e;
1499
+ }
1500
+ return;
1501
+ }
694
1502
  let found: any;
695
1503
  try {
696
1504
  const reg = (ctx as any)?.modelRegistry;
@@ -707,10 +1515,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
707
1515
  ) {
708
1516
  (pi as any).setThinkingLevel?.(defaultModel.thinking);
709
1517
  }
710
- ctx.ui.notify(
711
- `Default mode: model → ${defaultModel.provider}/${defaultModel.id}${defaultModel.thinking ? ` (${defaultModel.thinking})` : ""}`,
712
- "info",
713
- );
1518
+ if (!opts?.quiet) {
1519
+ ctx.ui.notify(
1520
+ `Default mode: model → ${defaultModel.provider}/${defaultModel.id}${defaultModel.thinking ? ` (${defaultModel.thinking})` : ""}`,
1521
+ "info",
1522
+ );
1523
+ }
714
1524
  } catch (_e) {
715
1525
  void _e;
716
1526
  }
@@ -737,7 +1547,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
737
1547
  } as any);
738
1548
  }
739
1549
 
740
- async function setPlanMode(ctx: ExtensionContext) {
1550
+ async function setPlanMode(
1551
+ ctx: ExtensionContext,
1552
+ opts?: ModeSwitchOptions,
1553
+ ) {
741
1554
  if (workflowMode === "plan") {
742
1555
  ctx.ui.notify(
743
1556
  "Already in Plan mode — read-only. Press Tab to cycle: Plan → Build → Default. Or /build to switch to Build.",
@@ -752,10 +1565,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
752
1565
  if (toolsBeforePlanMode === undefined)
753
1566
  toolsBeforePlanMode = pi.getActiveTools();
754
1567
  pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
755
- ctx.ui.notify(
756
- "Plan mode enabled — read-only. Explore + questionnaire loop, then write plan to .pi/plans/. Press Tab to cycle: Plan → Build → Default. Or /build to switch to Build.",
757
- "info",
758
- );
1568
+ if (!opts?.quiet) {
1569
+ ctx.ui.notify(
1570
+ "Plan mode enabled — read-only. Explore + questionnaire loop, then write plan to .pi/plans/. Press Tab to cycle: Plan → Build → Default. Or /build to switch to Build.",
1571
+ "info",
1572
+ );
1573
+ }
759
1574
  try {
760
1575
  fs.mkdirSync(path.join(ctx.cwd, CONFIG_DIR_NAME, "plans"), {
761
1576
  recursive: true,
@@ -764,15 +1579,20 @@ export default function workflowExtension(pi: ExtensionAPI) {
764
1579
  void _e;
765
1580
  }
766
1581
  try {
767
- await applyModeModel(ctx);
1582
+ await applyModeModel(ctx, opts);
768
1583
  } catch (_e) {
769
1584
  void _e;
770
1585
  }
1586
+ // No PLAN band: the footer status already shows the mode, and a band here
1587
+ // broke Pi's status-line dedupe on every Tab cycle (see plan 2026-09-19).
771
1588
  updateStatus(ctx);
772
1589
  persistState();
773
1590
  }
774
1591
 
775
- async function setBuildMode(ctx: ExtensionContext) {
1592
+ async function setBuildMode(
1593
+ ctx: ExtensionContext,
1594
+ opts?: ModeSwitchOptions,
1595
+ ) {
776
1596
  if (workflowMode === "build") {
777
1597
  ctx.ui.notify(
778
1598
  "Already in Build mode — full access. Press Tab to cycle: Build → Default → Plan. Or /plan to switch to Plan.",
@@ -793,20 +1613,27 @@ export default function workflowExtension(pi: ExtensionAPI) {
793
1613
  if (todoItems.length === 0 && currentPlanFile) {
794
1614
  loadTodosFromPlan(currentPlanFile, ctx.cwd);
795
1615
  }
796
- ctx.ui.notify(
797
- "Build mode — full access restored. Press Tab to cycle: Build → Default → Plan. Or /plan to switch to Plan.",
798
- "info",
799
- );
1616
+ if (!opts?.quiet) {
1617
+ ctx.ui.notify(
1618
+ "Build mode — full access restored. Press Tab to cycle: Build → Default → Plan. Or /plan to switch to Plan.",
1619
+ "info",
1620
+ );
1621
+ }
800
1622
  try {
801
- await applyModeModel(ctx);
1623
+ await applyModeModel(ctx, opts);
802
1624
  } catch (_e) {
803
1625
  void _e;
804
1626
  }
805
1627
  updateStatus(ctx);
806
1628
  persistState();
1629
+ // Tab-to-Build leaves Plan+Review behind; release the reviewer session.
1630
+ await teardownReview();
807
1631
  }
808
1632
 
809
- async function setDefaultMode(ctx: ExtensionContext) {
1633
+ async function setDefaultMode(
1634
+ ctx: ExtensionContext,
1635
+ opts?: ModeSwitchOptions,
1636
+ ) {
810
1637
  if (workflowMode === null) {
811
1638
  ctx.ui.notify(
812
1639
  "Already in Default mode — no workflow. Press Tab or /plan to switch to Plan.",
@@ -829,22 +1656,27 @@ export default function workflowExtension(pi: ExtensionAPI) {
829
1656
  // Restore the model that was active in Default mode (don't leak the
830
1657
  // Build/Plan role model into Default).
831
1658
  try {
832
- await restoreDefaultModel(ctx);
1659
+ await restoreDefaultModel(ctx, opts);
833
1660
  } catch (_e) {
834
1661
  void _e;
835
1662
  }
836
1663
  updateStatus(ctx);
837
1664
  persistState();
838
- ctx.ui.notify(
839
- "Default mode — workflow off, full access. Press Tab to cycle: Default → Plan → Build. Or /plan to switch to Plan.",
840
- "info",
841
- );
1665
+ if (!opts?.quiet) {
1666
+ ctx.ui.notify(
1667
+ "Default mode — workflow off, full access. Press Tab to cycle: Default → Plan → Build. Or /plan to switch to Plan.",
1668
+ "info",
1669
+ );
1670
+ }
842
1671
  }
843
1672
 
844
1673
  async function cycleWorkflowMode(ctx: ExtensionContext) {
845
- if (workflowMode === null) await setPlanMode(ctx);
846
- else if (workflowMode === "plan") await setBuildMode(ctx);
847
- else await setDefaultMode(ctx); // "build" → null (default)
1674
+ // Keyboard cycling is silent: no chat line, no band. The footer status is
1675
+ // the confirmation (see plan 2026-09-19).
1676
+ const opts: ModeSwitchOptions = { quiet: true };
1677
+ if (workflowMode === null) await setPlanMode(ctx, opts);
1678
+ else if (workflowMode === "plan") await setBuildMode(ctx, opts);
1679
+ else await setDefaultMode(ctx, opts); // "build" → null (default)
848
1680
  }
849
1681
 
850
1682
  // legacy alias for backward compat
@@ -922,6 +1754,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
922
1754
  ) {
923
1755
  workflowMode = "build";
924
1756
  syncLegacyFlags();
1757
+ // Approve path: both Plan and Review modes are now off in favour of Build.
1758
+ await teardownReview();
925
1759
  currentPlanFile = planPath;
926
1760
  pi.setActiveTools(
927
1761
  toolsBeforePlanMode ?? getBuildTools(pi.getActiveTools()),
@@ -1034,6 +1868,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
1034
1868
  awaitingDecision = false;
1035
1869
  lastHandoffAt = undefined;
1036
1870
  lastReviewId = null;
1871
+ // Feedback cycle: the next plan revision gets a fresh review round, and the
1872
+ // outstanding findings are carried forward so the reviewer can confirm they
1873
+ // were actually addressed rather than re-raising them.
1874
+ reviewRound = 0;
1875
+ lastReviewedPlanHash = undefined;
1037
1876
  persistState();
1038
1877
  try {
1039
1878
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -1475,13 +2314,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
1475
2314
  case "done":
1476
2315
  case "pending": {
1477
2316
  const ref =
1478
- params.label !== undefined
1479
- ? String(params.label)
1480
- : params.step !== undefined
1481
- ? String(params.step)
1482
- : params.text !== undefined
1483
- ? String(params.text)
1484
- : undefined;
2317
+ params.label === undefined
2318
+ ? params.step === undefined
2319
+ ? params.text === undefined
2320
+ ? undefined
2321
+ : String(params.text)
2322
+ : String(params.step)
2323
+ : String(params.label);
1485
2324
  if (ref === undefined)
1486
2325
  return {
1487
2326
  content: [
@@ -1597,11 +2436,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
1597
2436
  }
1598
2437
  case "toggle": {
1599
2438
  const ref =
1600
- params.label !== undefined
1601
- ? String(params.label)
1602
- : params.step !== undefined
1603
- ? String(params.step)
1604
- : undefined;
2439
+ params.label === undefined
2440
+ ? params.step === undefined
2441
+ ? undefined
2442
+ : String(params.step)
2443
+ : String(params.label);
1605
2444
  if (ref === undefined)
1606
2445
  return {
1607
2446
  content: [
@@ -1773,8 +2612,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
1773
2612
  ctx.ui.notify(msg, "info");
1774
2613
  };
1775
2614
 
1776
- // Bare /role → interactive config screen (3 blocks: planner /
1777
- // explorer / builder + custom roles). Headless → text summary.
2615
+ // Bare /role → interactive config screen (one block per role:
2616
+ // planner / explorer / builder / reviewer + custom roles).
2617
+ // Headless → text summary.
1778
2618
  if (!sub) {
1779
2619
  if ((ctx as any)?.hasUI) {
1780
2620
  await openRolePicker(ctx as any);
@@ -1924,7 +2764,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1924
2764
  if (sub === "reset") {
1925
2765
  roleConfig = defaultRoleConfig();
1926
2766
  saveAndSync(
1927
- "Roles reset to defaults — one model per role (planner/explorer/builder).",
2767
+ "Roles reset to defaults — one model per role (planner/explorer/builder/reviewer).",
1928
2768
  );
1929
2769
  return;
1930
2770
  }
@@ -2037,9 +2877,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
2037
2877
  }
2038
2878
 
2039
2879
  function orderedRoleTabs(): Role[] {
2880
+ // Built-in roles first in their declared order, then custom roles.
2881
+ // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
2040
2882
  const rank = (name: string) => {
2041
- const i = ["planner", "explorer", "builder"].indexOf(name.toLowerCase());
2042
- return i === -1 ? 99 : i;
2883
+ const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
2884
+ name.toLowerCase(),
2885
+ );
2886
+ return i === -1 ? BUILT_IN_ROLES.length : i;
2043
2887
  };
2044
2888
  return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
2045
2889
  }
@@ -2535,7 +3379,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
2535
3379
  try {
2536
3380
  const res = await runSingleAgent(
2537
3381
  job.cwd ?? cwd,
2538
- ag,
3382
+ ag.role ? ag : { ...ag, role: "explorer" },
2539
3383
  job.task,
2540
3384
  signal,
2541
3385
  );
@@ -3458,6 +4302,29 @@ export default function workflowExtension(pi: ExtensionAPI) {
3458
4302
 
3459
4303
  // Also allow /btw via input event when typed as message prefix
3460
4304
  pi.on("input", async (event, _ctx) => {
4305
+ // Input follows the ACTIVE mode: while a review round is in flight the
4306
+ // reviewer is the active agent, so the prompt is delivered there. Delivery
4307
+ // failures fall through to the plan agent — a message is never swallowed.
4308
+ if (typeof event.text === "string" && event.text.trim().length > 0) {
4309
+ const rt = reviewRuntime;
4310
+ if (
4311
+ shouldRouteInputToReviewer({
4312
+ reviewEnabled: reviewEnabled(),
4313
+ reviewActive: rt?.isRoundActive() === true,
4314
+ hasReviewerSession: rt?.isAlive() === true,
4315
+ })
4316
+ ) {
4317
+ try {
4318
+ const delivered = await rt!.sendUserMessage(event.text, event.images);
4319
+ if (delivered) {
4320
+ pushReviewNote("user", `you → reviewer: ${event.text.trim()}`);
4321
+ return { action: "handled" } as any;
4322
+ }
4323
+ } catch (_e) {
4324
+ void _e;
4325
+ }
4326
+ }
4327
+ }
3461
4328
  if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
3462
4329
  const note = event.text.slice(5).trim();
3463
4330
  if (note) {
@@ -3476,6 +4343,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
3476
4343
  // ── Session & Agent lifecycle ────────────────────────────────────
3477
4344
 
3478
4345
  pi.on("session_start", async (event, ctx) => {
4346
+ // Roles are file-backed; load them up-front so every read path (status,
4347
+ // band label, reviewer runtime) sees the real config even in a fresh
4348
+ // session that has no persisted workflow entry yet.
4349
+ try {
4350
+ ensureRoleConfig((ctx as any)?.cwd);
4351
+ notifyRoleConfigFallback(ctx as ExtensionContext);
4352
+ } catch (_e) {
4353
+ void _e;
4354
+ }
3479
4355
  // restore state from custom entries (supports both legacy enabled/executing and new mode field)
3480
4356
  try {
3481
4357
  const entries = ctx.sessionManager.getBranch();
@@ -3581,6 +4457,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
3581
4457
  ) {
3582
4458
  checkpoints.set(e.data.entryId, e.data.ref);
3583
4459
  }
4460
+ // Seed the band gate with the newest band on the branch so a reload
4461
+ // cannot re-emit an entry that is already the last thing shown.
4462
+ if (e.type === "custom" && e.customType === REVIEW_MODE_ENTRY_TYPE) {
4463
+ reviewBandGate.seed(e.data as ReviewModeBandData);
4464
+ }
3584
4465
  }
3585
4466
  } catch (_e) {
3586
4467
  void _e;
@@ -3668,6 +4549,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
3668
4549
  }
3669
4550
  ctx.ui.setStatus("workflow", undefined);
3670
4551
  ctx.ui.setWidget("workflow-todos", undefined);
4552
+ // Release the reviewer session (and its custom tools) on shutdown so a
4553
+ // reload or quit never leaves an orphaned in-process session behind.
4554
+ await teardownReview();
3671
4555
  currentSessionCtx = null;
3672
4556
  });
3673
4557
 
@@ -3705,12 +4589,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
3705
4589
  // Block edits/writes + bash gating
3706
4590
  pi.on("tool_call", async (event, ctx) => {
3707
4591
  const cwd = (ctx as ExtensionContext).cwd;
3708
- if (event.toolName === "bash" && workflowMode === "plan") {
4592
+ // Resolve the role per call: an env-carried role (spawned subagent) wins,
4593
+ // then the workflow mode (plan → planner, build → builder). Read lazily —
4594
+ // `workflowMode` is null while the extension body is evaluated and changes
4595
+ // on every Tab/mode switch.
4596
+ const gateRole: WorkflowRole | undefined = resolveSessionRole({
4597
+ envRole: process.env[WORKFLOW_ROLE_ENV],
4598
+ workflowMode,
4599
+ });
4600
+ const gatePolicy = gateRole ? policyForRole(gateRole) : undefined;
4601
+ const restricted =
4602
+ gatePolicy !== undefined && gatePolicy.commandClass !== "full";
4603
+
4604
+ // PowerShell syntax cannot be classified by the POSIX allowlist: fail closed.
4605
+ if (restricted && event.toolName === "powershell") {
4606
+ return {
4607
+ block: true,
4608
+ reason: `${gateRole} role: the powershell tool is blocked (use bash with allowlisted read-only/verify commands, or /build for unrestricted access).`,
4609
+ } as any;
4610
+ }
4611
+
4612
+ if (restricted && gateRole && event.toolName === "bash") {
3709
4613
  const cmd = (event.input as any).command as string;
3710
- // Narrow exception: allow mkdir -p .pi/plans as fallback for ensuring directory exists.
3711
- // Bash writes via >, >>, tee, cp, mv stay blocked — plan file must use write tool (Claude Code parity).
4614
+ // Narrow planner-only exception: allow mkdir -p .pi/plans as a fallback
4615
+ // for ensuring the plans directory exists. Bash writes via >, >>, tee,
4616
+ // cp, mv stay blocked — the plan file must use the write tool.
3712
4617
  const lower = cmd.toLowerCase();
3713
4618
  const isMkdirPlans =
4619
+ gateRole === "planner" &&
3714
4620
  /^\s*mkdir\s+(-p\s+)?/i.test(cmd) &&
3715
4621
  (lower.includes(".pi/plans") || lower.includes(".pi\\plans"));
3716
4622
  if (isMkdirPlans) {
@@ -3723,10 +4629,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
3723
4629
  }
3724
4630
  return;
3725
4631
  }
3726
- if (!isSafeCommand(cmd)) {
4632
+ const refusal = explainCommandRefusal(gateRole, cmd);
4633
+ if (refusal) {
4634
+ const planHint =
4635
+ gateRole === "planner" && refusal.kind === "forbidden-syntax"
4636
+ ? ' Use write({path: ".pi/plans/<date>-<slug>.md"}) for plans — not bash >.'
4637
+ : "";
4638
+ const hint = refusal.suggestion
4639
+ ? ` ${refusal.suggestion}`
4640
+ : " Use /build or Tab for unrestricted access.";
3727
4641
  return {
3728
4642
  block: true,
3729
- reason: `Plan mode: command blocked (not allowlisted). Use /build or Tab to switch to Build mode. Use write({path: ".pi/plans/<date>-<slug>.md"}) for plans — not bash >.\nCommand: ${cmd}`,
4643
+ reason: `${gateRole} role: command blocked (${gatePolicy?.description ?? "restricted"}) — ${refusal.detail}.${hint}${planHint}\nCommand: ${cmd}`,
3730
4644
  } as any;
3731
4645
  }
3732
4646
  }
@@ -3781,6 +4695,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
3781
4695
  void _e;
3782
4696
  }
3783
4697
  }
4698
+ // Non-planner restricted roles (e.g. an explorer subagent whose custom
4699
+ // agent definition requests write) are read-only: block edit/write.
4700
+ if (
4701
+ restricted &&
4702
+ gateRole !== "planner" &&
4703
+ (event.toolName === "edit" || event.toolName === "write")
4704
+ ) {
4705
+ return {
4706
+ block: true,
4707
+ reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
4708
+ } as any;
4709
+ }
3784
4710
  // In build mode, ensure todos exist before first real edit
3785
4711
  if (
3786
4712
  (event.toolName === "edit" || event.toolName === "write") &&
@@ -3943,12 +4869,17 @@ export default function workflowExtension(pi: ExtensionAPI) {
3943
4869
  : "";
3944
4870
  btwNotes = []; // consume
3945
4871
  const today = getUtcDatePrefix();
4872
+ // One neutral line only: Plan Mode must NOT see the reviewer's prompt,
4873
+ // findings, or transcript (requirement: Plan cannot see Review's context).
4874
+ const reviewNote = reviewEnabled()
4875
+ ? `\n\n[REVIEW MODE ACTIVE] An independent reviewer runs after Plan Mode finishes: it audits this plan and may rewrite it before you see it. Write a complete, self-contained, framework-aligned plan where every requirement maps to at least one step.`
4876
+ : "";
3946
4877
  return {
3947
4878
  message: {
3948
4879
  customType: "workflow-plan-context",
3949
4880
  content: `[PLAN MODE ACTIVE — Today is ${today} (UTC). Use this date as the <date> prefix.] — read-only exploration.\n\nRestrictions:\n- edit/write blocked except .pi/plans/ — use the write tool for that path (not bash). Example: write({path: ".pi/plans/${today}-my-feature.md", content: "# Plan: ..."})
3950
- - bash limited to read-only allowlist (no >, >>, mkdir outside .pi/plans). Do not use bash to write the plan file.\n- Use explore tool (subagents) in parallel for codebase recon\n- Use questionnaire tool for clarifications: 1-4 questions at once, first option = recommendation. Questionnaire appends "Type something." automatically — do NOT add Other/Type something in options.
3951
- - Loop: explore → questionnaire → re-explore until no open questions.\n- Then write comprehensive plan to .pi/plans/<date>-<slug>.md where <date> is Today (${today}) and <slug> is kebab-case ≤40 chars, with headings: # Plan: <title>, ## Context, ## Decisions, ## Exploration Summary, ## Plan Steps (numbered 1..N), ## Risks, ## Verification.\n- If you need to verify the date, run: bash {command: "date -u +%F"} (UTC) — do not guess the date. The extension will auto-correct a wrong prefix to ${today}.\n- Keep asking until everything is clear. Do NOT edit source files.\n- Use brave-search skill via bash if web research needed.\n\n[TODO LIST — when this plan is approved, its steps become the todo list. Steps are numbered GLOBALLY 1..N; always reference them by those numbers. Update status with workflow_todo {action:"update", todos:[…COMPLETE LIST…]} — send the whole list, mark a step completed IMMEDIATELY when done (never batch), keep exactly one in_progress.]\n${btwBlock}`,
4881
+ - bash limited to read-only + test/lint/typecheck commands (ls, cat, rg, git log, npm test, npm run lint/typecheck, eslint, tsc --noEmit). File redirects (> and >>) to real files, installs, sed/awk, mkdir outside .pi/plans and the powershell tool stay blocked — but 2>/dev/null and 2>&1 are allowed. Do not use bash to write the plan file.\n- Use explore tool (subagents) in parallel for codebase recon\n- Use questionnaire tool for clarifications: 1-4 questions at once, first option = recommendation. Questionnaire appends "Type something." automatically — do NOT add Other/Type something in options.
4882
+ - Loop: explore → questionnaire → re-explore until no open questions.\n- Then write comprehensive plan to .pi/plans/<date>-<slug>.md where <date> is Today (${today}) and <slug> is kebab-case ≤40 chars, with headings: # Plan: <title>, ## Context, ## Decisions, ## Exploration Summary, ## Plan Steps (numbered 1..N), ## Risks, ## Verification.\n- If you need to verify the date, run: bash {command: "date -u +%F"} (UTC) — do not guess the date. The extension will auto-correct a wrong prefix to ${today}.\n- Keep asking until everything is clear. Do NOT edit source files.\n- Use brave-search skill via bash if web research needed.\n\n[TODO LIST — when this plan is approved, its steps become the todo list. Steps are numbered GLOBALLY 1..N; always reference them by those numbers. Update status with workflow_todo {action:"update", todos:[…COMPLETE LIST…]} — send the whole list, mark a step completed IMMEDIATELY when done (never batch), keep exactly one in_progress.]${reviewNote}\n${btwBlock}`,
3952
4883
  display: false,
3953
4884
  },
3954
4885
  } as any;
@@ -4555,6 +5486,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
4555
5486
  } catch (_e) {
4556
5487
  void _e;
4557
5488
  }
5489
+
5490
+ // ── Review Mode: audit (and possibly rewrite) the plan before the user
5491
+ // sees it. Runs inside runHandoff, so the existing handoffInFlight mutex
5492
+ // already serialises it. On any failure the author's plan is kept.
5493
+ if (planTextForRender && reviewEnabled()) {
5494
+ try {
5495
+ notifyReview("auditing the plan before handoff…", "info");
5496
+ planTextForRender = await runReviewStep(
5497
+ planTextForRender,
5498
+ ctx as ExtensionContext,
5499
+ );
5500
+ } catch (_e) {
5501
+ void _e;
5502
+ }
5503
+ }
4558
5504
  // Fallback when hasUI is false: still show handoff via followUp message (Claude Code parity for headless)
4559
5505
  if (!hasUI && planTextForRender && !awaitingDecision) {
4560
5506
  try {
@@ -4611,6 +5557,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
4611
5557
  payload: {
4612
5558
  planContent: planTextForRender!,
4613
5559
  planFilePath: planPath,
5560
+ // Provenance so the browser/inline view can show that this
5561
+ // plan was independently reviewed (and by what).
5562
+ reviewSummary: reviewState
5563
+ ? {
5564
+ modelLabel: reviewState.modelLabel,
5565
+ round: reviewState.round,
5566
+ verdict: reviewState.verdict,
5567
+ findings: reviewState.findings.length,
5568
+ }
5569
+ : undefined,
4614
5570
  },
4615
5571
  respond: (res: any) => {
4616
5572
  if (!settled) {