@dieulc/workflow 0.2.0 → 0.3.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/README.md +188 -6
- package/index.ts +1021 -65
- package/package.json +9 -4
- package/permissions.ts +1042 -0
- package/review-runtime.ts +931 -0
- package/review-tools.ts +749 -0
- package/review-ui.ts +421 -0
- package/review.ts +1704 -0
- package/roles.ts +164 -57
- package/utils.ts +474 -92
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
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
|
-
|
|
1376
|
+
current =
|
|
618
1377
|
(pi as any)?.getCurrentModel?.() ?? (ctx as any)?.model ?? null;
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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(
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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(
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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(
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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(
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
1479
|
-
?
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
|
1601
|
-
?
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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 (
|
|
1777
|
-
// explorer / builder + custom roles).
|
|
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 =
|
|
2042
|
-
|
|
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
|
-
|
|
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
|
|
3711
|
-
// Bash writes via >, >>, tee,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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) {
|