@underactive/pi-topping-moa-fusion 0.1.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -0
  4. package/agents/mf-plan.md +43 -0
  5. package/agents/moa-debater.md +37 -0
  6. package/agents/moa-explore.md +56 -0
  7. package/agents/moa-opinion.md +29 -0
  8. package/agents/moa-proposer.md +49 -0
  9. package/agents/moa-synthesizer.md +124 -0
  10. package/agents/moa-verifier.md +67 -0
  11. package/index.ts +3 -0
  12. package/package.json +61 -0
  13. package/src/activityMeter.ts +193 -0
  14. package/src/agents/authoritative.ts +91 -0
  15. package/src/agents/defaults.ts +123 -0
  16. package/src/agents/discovery.ts +119 -0
  17. package/src/config/modelCatalogue.ts +54 -0
  18. package/src/config/planName.ts +74 -0
  19. package/src/config/rosters.ts +118 -0
  20. package/src/config/settings.ts +161 -0
  21. package/src/debate/debateContract.ts +89 -0
  22. package/src/debate/debateFanout.ts +285 -0
  23. package/src/debate/debateFile.ts +38 -0
  24. package/src/debate/debateResults.ts +115 -0
  25. package/src/debate/debateRounds.ts +61 -0
  26. package/src/debate/runDebate.ts +143 -0
  27. package/src/index.ts +283 -0
  28. package/src/moa/conflictContract.ts +49 -0
  29. package/src/moa/conflicts.ts +153 -0
  30. package/src/moa/contextContract.ts +52 -0
  31. package/src/moa/fanout.ts +152 -0
  32. package/src/moa/fanoutWiring.ts +88 -0
  33. package/src/moa/implementationRetry.ts +292 -0
  34. package/src/moa/modelRuntime.ts +87 -0
  35. package/src/moa/orchestration.ts +105 -0
  36. package/src/moa/planInfo.ts +57 -0
  37. package/src/moa/planlessRetry.ts +72 -0
  38. package/src/moa/reviewLoop.ts +170 -0
  39. package/src/moa/runContext.ts +118 -0
  40. package/src/moa/synthesis.ts +420 -0
  41. package/src/moa/verdicts.ts +81 -0
  42. package/src/moa/verification.ts +791 -0
  43. package/src/moa/verificationCriteria.ts +127 -0
  44. package/src/moa/verifyGate.ts +137 -0
  45. package/src/opinion/opinionContract.ts +21 -0
  46. package/src/opinion/opinionFanout.ts +135 -0
  47. package/src/opinion/opinionFile.ts +38 -0
  48. package/src/opinion/opinionResults.ts +73 -0
  49. package/src/opinion/runOpinion.ts +156 -0
  50. package/src/planning/askUserQuestion.ts +83 -0
  51. package/src/planning/instructions.ts +146 -0
  52. package/src/planning/modeState.ts +61 -0
  53. package/src/planning/planFile.ts +273 -0
  54. package/src/planning/planMode.ts +673 -0
  55. package/src/planning/tools/enterPlanMode.ts +165 -0
  56. package/src/planning/tools/exitPlanMode.ts +159 -0
  57. package/src/planning/tools/mfPlanSubagent.ts +311 -0
  58. package/src/planning/tools/shared.ts +19 -0
  59. package/src/planning/tools/writePlan.ts +33 -0
  60. package/src/runtime/activityTracking.ts +141 -0
  61. package/src/runtime/cancelRun.ts +134 -0
  62. package/src/runtime/mutationTripwire.ts +251 -0
  63. package/src/runtime/processPool.ts +55 -0
  64. package/src/runtime/results.ts +103 -0
  65. package/src/runtime/runner.ts +538 -0
  66. package/src/runtime/wire.ts +177 -0
  67. package/src/shared/functionKeys.ts +30 -0
  68. package/src/shared/modelRefs.ts +91 -0
  69. package/src/ui/agentStatus.ts +84 -0
  70. package/src/ui/agentTranscript.ts +112 -0
  71. package/src/ui/cancelOverlay.ts +191 -0
  72. package/src/ui/chrome.ts +151 -0
  73. package/src/ui/conflictOverlay.ts +363 -0
  74. package/src/ui/debateModelPicker.ts +273 -0
  75. package/src/ui/menu.ts +679 -0
  76. package/src/ui/moaModelPicker.ts +900 -0
  77. package/src/ui/moaProgressWidget.ts +910 -0
  78. package/src/ui/moaSetupOverlay.ts +368 -0
  79. package/src/ui/modelLabel.ts +61 -0
  80. package/src/ui/observeOverlay.ts +206 -0
  81. package/src/ui/opinionModelPicker.ts +246 -0
  82. package/src/ui/planReviewOverlay.ts +315 -0
  83. package/src/ui/promptEditor.ts +87 -0
  84. package/src/ui/rosterEditor.ts +310 -0
  85. package/src/ui/shimmer.ts +77 -0
  86. package/src/ui/toolActivity.ts +35 -0
  87. package/src/ui/twoPaneModelThinking.ts +272 -0
  88. package/src/ui/verificationFindingsOverlay.ts +137 -0
@@ -0,0 +1,33 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import { getPlanFilePath, writePlan } from "../planFile.ts";
5
+
6
+ export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean): void {
7
+ pi.registerTool({
8
+ name: "write_plan",
9
+ label: "Write Plan",
10
+ description: "Write or update content in the plan file. This is the ONLY file you can write during plan mode. Use this to build your plan incrementally.",
11
+ parameters: Type.Object({
12
+ content: Type.String({ description: "The full content to write to the plan file (replaces existing content)" }),
13
+ }),
14
+
15
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
16
+ if (!isEnabled()) {
17
+ return {
18
+ details: undefined,
19
+ content: [{ type: "text", text: "Error: Not in plan mode. Use /mf-plan to enter plan mode first." }],
20
+ isError: true,
21
+ };
22
+ }
23
+
24
+ const filePath = getPlanFilePath();
25
+ writePlan(params.content);
26
+
27
+ return {
28
+ content: [{ type: "text", text: `Plan written to ${filePath} (${params.content.length} chars)` }],
29
+ details: { filePath, length: params.content.length },
30
+ };
31
+ },
32
+ });
33
+ }
@@ -0,0 +1,141 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
3
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
4
+ import { StreamingWordCounter } from "../activityMeter.ts";
5
+ import { isNonnegativeFiniteNumber, isValidContentIndex, type WireAssistantMessageEvent } from "./wire.ts";
6
+
7
+ /** Max length of a one-line tool-activity description surfaced for progress UIs. */
8
+ const ACTIVITY_MAX = 80;
9
+
10
+ /** Collapse all whitespace/newlines to single spaces and trim. */
11
+ function collapseWhitespace(text: string): string {
12
+ return text.replace(/\s+/g, " ").trim();
13
+ }
14
+
15
+ /**
16
+ * Pick the most informative string argument for a one-line activity label.
17
+ * Prefers well-known tool arg names (command, pattern, path, …); falls back to
18
+ * the first non-empty string value.
19
+ */
20
+ function summarizeToolArgs(args: unknown): string {
21
+ if (!args || typeof args !== "object") return "";
22
+ const record = args as Record<string, unknown>;
23
+ const preferred = ["command", "pattern", "query", "path", "file", "glob", "dir", "url", "name"];
24
+ for (const key of preferred) {
25
+ const value = record[key];
26
+ if (typeof value === "string" && value.trim()) return collapseWhitespace(value);
27
+ }
28
+ for (const value of Object.values(record)) {
29
+ if (typeof value === "string" && value.trim()) return collapseWhitespace(value);
30
+ }
31
+ return "";
32
+ }
33
+
34
+ /** Format a `tool_execution_start` event into a single-line "toolName arg" label. */
35
+ export function formatToolActivity(toolName: string, args: unknown): string {
36
+ const detail = summarizeToolArgs(args);
37
+ const label = detail ? `${toolName} ${detail}` : String(toolName);
38
+ return stripTerminalSequences(collapseWhitespace(label).slice(0, ACTIVITY_MAX));
39
+ }
40
+
41
+ /** Cumulative generated-output reading for the fan-out activity monitor. */
42
+ export interface OutputActivity {
43
+ /** Tokens generated so far this run: confirmed turns plus the in-flight turn. */
44
+ tokens: number;
45
+ /**
46
+ * Bumped whenever exact provider usage replaces a differing estimate.
47
+ * Consumers reset their rate tracker on a change so the correction doesn't
48
+ * register as a burst of generation that never happened.
49
+ */
50
+ revision: number;
51
+ }
52
+
53
+ export class OutputActivityTracker {
54
+ #counter = new StreamingWordCounter();
55
+ #confirmed = 0;
56
+ #liveWords = 0;
57
+ #revision = 0;
58
+
59
+ /** Begin a new assistant turn, discarding only the previous turn's live estimate. */
60
+ messageStart(message: { role?: string } | undefined): void {
61
+ if (message?.role !== "assistant") return;
62
+ this.#resetTurn();
63
+ }
64
+
65
+ messageUpdate(assistantEvent: { type?: string; delta?: string } | undefined): void {
66
+ if (assistantEvent?.type !== "text_delta" && assistantEvent?.type !== "thinking_delta") return;
67
+ if (typeof assistantEvent.delta !== "string") return;
68
+ // Counted per stream kind so a word split across deltas isn't double
69
+ // counted, and interleaved thinking/text streams don't corrupt each other.
70
+ this.#liveWords += this.#counter.count(assistantEvent.delta, assistantEvent.type);
71
+ }
72
+
73
+ messageEnd(message: { role?: string; usage?: { output?: unknown } } | undefined): void {
74
+ if (message?.role !== "assistant") return;
75
+ const exact = message.usage?.output;
76
+ if (isNonnegativeFiniteNumber(exact)) {
77
+ this.#confirmed += exact;
78
+ if (exact !== this.#liveWords) this.#revision++;
79
+ } else {
80
+ this.#confirmed += this.#liveWords;
81
+ }
82
+ this.#resetTurn();
83
+ }
84
+
85
+ snapshot(): OutputActivity {
86
+ return { tokens: this.#confirmed + this.#liveWords, revision: this.#revision };
87
+ }
88
+
89
+ #resetTurn(): void {
90
+ this.#liveWords = 0;
91
+ this.#counter.reset();
92
+ }
93
+ }
94
+
95
+ type AssistantContentPart = AssistantMessage["content"][number];
96
+
97
+ /**
98
+ * Rebuilds the in-flight assistant message from streamed deltas.
99
+ *
100
+ * `message_update` carries only `contentIndex`-addressed deltas, so a live view
101
+ * of what an agent is writing has to be reassembled client-side. Tool calls
102
+ * materialise only at `toolcall_end`, once their arguments have finished
103
+ * streaming and are parseable.
104
+ */
105
+ export class PartialAssistantAssembler {
106
+ #template: AssistantMessage | undefined;
107
+ #parts: (AssistantContentPart | undefined)[] = [];
108
+
109
+ start(message: AgentMessage | undefined): void {
110
+ this.#template = message?.role === "assistant" ? message : undefined;
111
+ this.#parts = [];
112
+ }
113
+
114
+ apply(assistantEvent: WireAssistantMessageEvent | undefined): void {
115
+ if (!assistantEvent) return;
116
+ if (("contentIndex" in assistantEvent && !isValidContentIndex(assistantEvent.contentIndex))) return;
117
+ if (assistantEvent.type === "text_delta") {
118
+ const part = this.#parts[assistantEvent.contentIndex];
119
+ if (part?.type === "text") part.text += assistantEvent.delta;
120
+ else this.#parts[assistantEvent.contentIndex] = { type: "text", text: assistantEvent.delta };
121
+ } else if (assistantEvent.type === "thinking_delta") {
122
+ const part = this.#parts[assistantEvent.contentIndex];
123
+ if (part?.type === "thinking") part.thinking += assistantEvent.delta;
124
+ else this.#parts[assistantEvent.contentIndex] = { type: "thinking", thinking: assistantEvent.delta };
125
+ } else if (assistantEvent.type === "toolcall_end") {
126
+ this.#parts[assistantEvent.contentIndex] = assistantEvent.toolCall;
127
+ }
128
+ }
129
+
130
+ /** The message as it currently stands, or undefined before any content arrives. */
131
+ snapshot(): Message | undefined {
132
+ if (!this.#template) return undefined;
133
+ const content = this.#parts.filter((part) => part !== undefined);
134
+ return content.length > 0 ? { ...this.#template, content } : undefined;
135
+ }
136
+
137
+ clear(): void {
138
+ this.#template = undefined;
139
+ this.#parts = [];
140
+ }
141
+ }
@@ -0,0 +1,134 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Cancellation registry for in-flight subagent processes.
6
+ *
7
+ * One CancelRun is created per orchestration phase (MoA fan-out, synthesis
8
+ * round, tool-call parallel run). Each agent slot gets its own
9
+ * AbortController; the combined signal handed to runSingleAgent also folds in
10
+ * a run-wide "cancel all" controller and any upstream signal (e.g. pi's
11
+ * turn-abort signal in the mf_plan_subagent tool path).
12
+ *
13
+ * Slot indices are positional and shared by construction with the arrays the
14
+ * orchestrators build from the same source list: proposers[i] ↔ widget
15
+ * statuses[i] ↔ run.agents[i].
16
+ */
17
+
18
+ export type AgentCancelState = "running" | "cancelling" | "done" | "error" | "cancelled";
19
+
20
+ export interface TrackedAgent {
21
+ /** Display label, e.g. "openai/gpt-5.2" or "explore #1". */
22
+ label: string;
23
+ state: AgentCancelState;
24
+ controller: AbortController;
25
+ }
26
+
27
+ export class CancelRun {
28
+ readonly agents: TrackedAgent[] = [];
29
+ cancelAllRequested = false;
30
+ private readonly allController = new AbortController();
31
+ private readonly listeners = new Set<() => void>();
32
+
33
+ /** Register one agent slot; returns its index and the combined abort signal. */
34
+ add(label: string, upstream?: AbortSignal): { index: number; signal: AbortSignal } {
35
+ const controller = new AbortController();
36
+ this.agents.push({ label, state: "running", controller });
37
+ const signals = [controller.signal, this.allController.signal];
38
+ if (upstream) signals.push(upstream);
39
+ return { index: this.agents.length - 1, signal: AbortSignal.any(signals) };
40
+ }
41
+
42
+ /** Cancel one agent. No-op unless it is still running (race-safe: an agent may settle while the overlay is open). */
43
+ cancel(index: number): void {
44
+ const agent = this.agents[index];
45
+ if (!agent || agent.state !== "running") return;
46
+ agent.state = "cancelling";
47
+ agent.controller.abort();
48
+ this.notify();
49
+ }
50
+
51
+ /** Cancel every running agent and mark the whole run as user-cancelled. */
52
+ cancelAll(): void {
53
+ if (this.cancelAllRequested) return;
54
+ this.cancelAllRequested = true;
55
+ for (const agent of this.agents) {
56
+ if (agent.state === "running") agent.state = "cancelling";
57
+ }
58
+ this.allController.abort();
59
+ this.notify();
60
+ }
61
+
62
+ /** Record an agent's final state once its subprocess result arrives. */
63
+ settle(index: number, state: "done" | "error" | "cancelled"): void {
64
+ const agent = this.agents[index];
65
+ if (!agent) return;
66
+ agent.state = state;
67
+ this.notify();
68
+ }
69
+
70
+ /** Subscribe to state changes (for live overlay re-renders). Returns an unsubscribe function. */
71
+ onChange(listener: () => void): () => void {
72
+ this.listeners.add(listener);
73
+ return () => this.listeners.delete(listener);
74
+ }
75
+
76
+ private notify(): void {
77
+ for (const listener of this.listeners) listener();
78
+ }
79
+ }
80
+
81
+ /** Extra per-row display data echoed from the progress widget / tool progress. */
82
+ export interface CancelRowExtras {
83
+ contextTokens?: number;
84
+ contextWindow?: number;
85
+ activity?: string;
86
+ loopCount?: number;
87
+ }
88
+
89
+ /**
90
+ * Published by an orchestrator while agents are in flight so the input
91
+ * triggers (ESC listener, F4 shortcut) can find the live run and
92
+ * open the cancel overlay against it.
93
+ */
94
+ export interface CancelSession {
95
+ /** Overlay title, e.g. "MoA fan-out — running proposers". */
96
+ title: string;
97
+ /** The live run, or undefined when nothing is in flight (ESC passes through). */
98
+ run: CancelRun | undefined;
99
+ /** Optional per-row display extras (context bar, tool activity, loop badge). */
100
+ getExtras?: (index: number) => CancelRowExtras | undefined;
101
+ /** True while the cancel overlay is mounted; guards double-open races. */
102
+ overlayOpen: boolean;
103
+ /** Registered by the overlay while open so orchestrators can force-close it before competing UIs. */
104
+ closeOverlay?: () => void;
105
+ }
106
+
107
+ export interface CancelOverlayEscHost {
108
+ getActiveObserveSession(): { overlayOpen: boolean } | undefined;
109
+ openCancelOverlayIfActive(ctx: ExtensionContext): boolean;
110
+ }
111
+
112
+ /**
113
+ * Subscribes ESC to open the cancel overlay while `session.run` is in
114
+ * flight, passing ESC through otherwise so the editor, model pickers, and
115
+ * observe overlay keep their own ESC handling. Terminal input listeners run
116
+ * ahead of pi's key-release filter, so under the Kitty keyboard protocol the
117
+ * ESC *release* arrives here a tick after the press closed an overlay and
118
+ * cleared `overlayOpen` — reopening it instantly. Only a key press may open
119
+ * the overlay. Returns undefined outside the TUI.
120
+ */
121
+ export function subscribeCancelOverlayOnEsc(
122
+ ctx: ExtensionContext,
123
+ host: CancelOverlayEscHost,
124
+ session: CancelSession,
125
+ ): (() => void) | undefined {
126
+ if (ctx.mode !== "tui") return undefined;
127
+ return ctx.ui.onTerminalInput((data) => {
128
+ if (isKeyRelease(data) || !matchesKey(data, Key.escape)) return undefined;
129
+ if (host.getActiveObserveSession()?.overlayOpen) return undefined;
130
+ if (session.overlayOpen) return undefined;
131
+ if (!session.run) return undefined;
132
+ return host.openCancelOverlayIfActive(ctx) ? { consume: true } : undefined;
133
+ });
134
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Working-tree mutation tripwire for plan mode.
3
+ *
4
+ * Plan-mode subprocesses are spawned read-only (pi `--tools` allowlist plus
5
+ * the PI_CURSOR_FORCE_MODE handshake for agentic provider bridges), but that
6
+ * boundary is ultimately cooperative for providers that run full agents with
7
+ * their own local tools outside pi's tool loop. This module detects — via
8
+ * `git status --porcelain` snapshots — any working-tree change that appears
9
+ * while planning agents run, so the orchestration can warn loudly instead of
10
+ * silently absorbing rogue edits.
11
+ */
12
+
13
+ import { execFile } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { lstat, readFile, readdir } from "node:fs/promises";
16
+ import * as path from "node:path";
17
+ import { promisify } from "node:util";
18
+ import { mapWithConcurrencyLimit } from "./processPool.ts";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** `git status --porcelain` snapshot, or null when unavailable (not a git repo, no git). */
23
+ export async function captureWorkingTreeState(cwd: string): Promise<string | null> {
24
+ try {
25
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "-z"], {
26
+ cwd,
27
+ maxBuffer: 10 * 1024 * 1024,
28
+ });
29
+ const fields = stdout.split("\0");
30
+ const lines: string[] = [];
31
+ for (let index = 0; index < fields.length; index++) {
32
+ const field = fields[index];
33
+ if (!field) continue;
34
+ const status = field.slice(0, 2);
35
+ const destination = field.slice(3);
36
+ if (status.includes("R") || status.includes("C")) {
37
+ const source = fields[++index];
38
+ if (source === undefined) continue;
39
+ lines.push(`${status} ${JSON.stringify(source)} -> ${JSON.stringify(destination)}`);
40
+ } else {
41
+ lines.push(`${status} ${JSON.stringify(destination)}`);
42
+ }
43
+ }
44
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ interface PorcelainEntry {
51
+ displayPath: string;
52
+ relativePath: string;
53
+ }
54
+
55
+ function parseJsonPathPrefix(value: string): { path: string; end: number } | undefined {
56
+ if (!value.startsWith('"')) return undefined;
57
+ let escaped = false;
58
+ for (let index = 1; index < value.length; index++) {
59
+ const char = value[index];
60
+ if (escaped) {
61
+ escaped = false;
62
+ continue;
63
+ }
64
+ if (char === "\\") {
65
+ escaped = true;
66
+ continue;
67
+ }
68
+ if (char !== '"') continue;
69
+ try {
70
+ const parsed: unknown = JSON.parse(value.slice(0, index + 1));
71
+ return typeof parsed === "string" ? { path: parsed, end: index + 1 } : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ /** Parse canonical JSON-quoted snapshots and legacy human-readable porcelain lines. */
80
+ function parsePorcelainEntry(line: string): PorcelainEntry {
81
+ const field = line.slice(3).trim();
82
+ const first = parseJsonPathPrefix(field);
83
+ if (first) {
84
+ const remainder = field.slice(first.end);
85
+ if (remainder.startsWith(" -> ")) {
86
+ const second = parseJsonPathPrefix(remainder.slice(4));
87
+ if (second) return { displayPath: `${first.path} -> ${second.path}`, relativePath: second.path };
88
+ }
89
+ return { displayPath: first.path, relativePath: first.path };
90
+ }
91
+ const renameAt = field.lastIndexOf(" -> ");
92
+ return renameAt >= 0
93
+ ? { displayPath: field, relativePath: field.slice(renameAt + 4) }
94
+ : { displayPath: field, relativePath: field };
95
+ }
96
+
97
+ /** "XY path" / "XY old -> new" porcelain line → displayable path. */
98
+ export function porcelainPath(line: string): string {
99
+ return parsePorcelainEntry(line).displayPath;
100
+ }
101
+
102
+ const MAX_HASH_FILE_BYTES = 16 * 1024 * 1024;
103
+ const MAX_HASH_DIRECTORY_BYTES = 16 * 1024 * 1024;
104
+ const MAX_HASH_DIRECTORY_ENTRIES = 2048;
105
+
106
+ interface DirectoryEntryFingerprint {
107
+ hash: string;
108
+ statSignature: string;
109
+ }
110
+
111
+ async function fingerprintDirectory(
112
+ directory: string,
113
+ previous?: WorkingTreeFingerprint,
114
+ ): Promise<WorkingTreeFingerprint> {
115
+ const hash = createHash("sha256");
116
+ const directoryEntries = new Map<string, DirectoryEntryFingerprint>();
117
+ let entryCount = 0;
118
+ let contentBytes = 0;
119
+ let truncated = false;
120
+
121
+ const walk = async (current: string, prefix: string): Promise<void> => {
122
+ const entries = await readdir(current, { withFileTypes: true });
123
+ entries.sort((left, right) => left.name.localeCompare(right.name));
124
+ for (const entry of entries) {
125
+ if (entryCount >= MAX_HASH_DIRECTORY_ENTRIES) {
126
+ truncated = true;
127
+ return;
128
+ }
129
+ entryCount++;
130
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
131
+ const entryPath = path.join(current, entry.name);
132
+ const metadata = await lstat(entryPath);
133
+ const kind = entry.isDirectory() ? "d" : entry.isFile() ? "f" : entry.isSymbolicLink() ? "l" : "o";
134
+ hash.update(`${kind}:${relative}:${metadata.size}:${metadata.mtimeMs}\0`);
135
+ if (entry.isDirectory()) {
136
+ await walk(entryPath, relative);
137
+ if (truncated) return;
138
+ } else if (entry.isFile()
139
+ && metadata.size <= MAX_HASH_FILE_BYTES
140
+ && contentBytes + metadata.size <= MAX_HASH_DIRECTORY_BYTES) {
141
+ const statSignature = `${metadata.size}:${metadata.mtimeMs}`;
142
+ const prior = previous?.directoryEntries?.get(relative);
143
+ const contentHash = prior?.statSignature === statSignature
144
+ ? prior.hash
145
+ : createHash("sha256").update(await readFile(entryPath)).digest("hex");
146
+ contentBytes += metadata.size;
147
+ directoryEntries.set(relative, { hash: contentHash, statSignature });
148
+ hash.update(contentHash);
149
+ }
150
+ }
151
+ };
152
+
153
+ await walk(directory, "");
154
+ return {
155
+ hash: `directory:${entryCount}:${contentBytes}:${truncated ? "truncated" : "complete"}:${hash.digest("hex")}`,
156
+ directoryEntries,
157
+ };
158
+ }
159
+
160
+ export interface WorkingTreeFingerprint {
161
+ hash: string;
162
+ statSignature?: string;
163
+ directoryEntries?: ReadonlyMap<string, DirectoryEntryFingerprint>;
164
+ }
165
+
166
+ /** Fingerprint every path currently mentioned by porcelain output. */
167
+ export async function hashWorkingTreeFiles(
168
+ cwd: string,
169
+ porcelain: string,
170
+ previous?: ReadonlyMap<string, WorkingTreeFingerprint>,
171
+ ): Promise<Map<string, WorkingTreeFingerprint>> {
172
+ const fingerprints = new Map<string, WorkingTreeFingerprint>();
173
+ const root = path.resolve(cwd);
174
+ await mapWithConcurrencyLimit(porcelain.split("\n").filter(Boolean), 8, async (line) => {
175
+ const { displayPath, relativePath } = parsePorcelainEntry(line);
176
+ try {
177
+ const filePath = path.resolve(root, relativePath);
178
+ if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes repository");
179
+ const metadata = await lstat(filePath);
180
+ if (metadata.isDirectory()) {
181
+ fingerprints.set(displayPath, await fingerprintDirectory(filePath, previous?.get(displayPath)));
182
+ return;
183
+ }
184
+ const statSignature = `${metadata.size}:${metadata.mtimeMs}`;
185
+ if (!metadata.isFile() || metadata.size > MAX_HASH_FILE_BYTES) {
186
+ fingerprints.set(displayPath, { hash: statSignature, statSignature });
187
+ return;
188
+ }
189
+ const prior = previous?.get(displayPath);
190
+ if (prior?.statSignature === statSignature) {
191
+ fingerprints.set(displayPath, prior);
192
+ return;
193
+ }
194
+ const content = await readFile(filePath);
195
+ fingerprints.set(displayPath, {
196
+ hash: createHash("sha256").update(content).digest("hex"),
197
+ statSignature,
198
+ });
199
+ } catch {
200
+ fingerprints.set(displayPath, { hash: "unreadable" });
201
+ }
202
+ });
203
+ return fingerprints;
204
+ }
205
+
206
+ /** Paths whose porcelain status differs between two snapshots (either direction). */
207
+ export function diffWorkingTreeStates(before: string, after: string): string[] {
208
+ const beforeLines = new Set(before.split("\n").filter(Boolean));
209
+ const afterLines = new Set(after.split("\n").filter(Boolean));
210
+ const changed = new Set<string>();
211
+ for (const line of afterLines) if (!beforeLines.has(line)) changed.add(porcelainPath(line));
212
+ for (const line of beforeLines) if (!afterLines.has(line)) changed.add(porcelainPath(line));
213
+ return [...changed];
214
+ }
215
+
216
+ export class MutationTripwire {
217
+ private baseline: string | null = null;
218
+ private baselineHashes: Map<string, WorkingTreeFingerprint> | null = null;
219
+
220
+ /** Snapshot the working tree before spawning planning agents. */
221
+ async arm(cwd: string): Promise<void> {
222
+ this.baseline = await captureWorkingTreeState(cwd);
223
+ this.baselineHashes = this.baseline === null ? null : await hashWorkingTreeFiles(cwd, this.baseline);
224
+ }
225
+
226
+ /**
227
+ * Paths changed since the last arm()/check(). Re-baselines on every call so
228
+ * subsequent checks only report NEW changes (a fan-out warning is not
229
+ * repeated after synthesis). Returns [] when git state is unavailable.
230
+ */
231
+ async check(cwd: string): Promise<string[]> {
232
+ if (this.baseline === null) return [];
233
+ const current = await captureWorkingTreeState(cwd);
234
+ if (current === null) return [];
235
+ const currentHashes = await hashWorkingTreeFiles(cwd, current, this.baselineHashes ?? undefined);
236
+ const changed = new Set(diffWorkingTreeStates(this.baseline, current));
237
+ for (const path of new Set([...(this.baselineHashes?.keys() ?? []), ...currentHashes.keys()])) {
238
+ if (this.baselineHashes?.get(path)?.hash !== currentHashes.get(path)?.hash) changed.add(path);
239
+ }
240
+ this.baseline = current;
241
+ this.baselineHashes = currentHashes;
242
+ return [...changed];
243
+ }
244
+ }
245
+
246
+ /** One-line warning for a non-empty change set, capped for notify() display. */
247
+ export function formatMutationWarning(phase: string, changed: string[]): string {
248
+ const shown = changed.slice(0, 6).join(", ");
249
+ const more = changed.length > 6 ? ` (+${changed.length - 6} more)` : "";
250
+ return `⚠ Repo files changed during ${phase} — planning agents must never modify the working tree. Review with git status / git diff: ${shown}${more}`;
251
+ }
@@ -0,0 +1,55 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+
3
+ /** Globally tracked spawned processes so /reload can kill them. */
4
+ export const trackedProcesses: Set<ChildProcess> = new Set();
5
+
6
+ /** Send SIGTERM, then force a still-live child down after the grace period. */
7
+ export function escalateKill(proc: ChildProcess, termTimeoutMs = 5000): void {
8
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
9
+ try { proc.kill("SIGTERM"); } catch { return; }
10
+ setTimeout(() => {
11
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
12
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
13
+ }, termTimeoutMs).unref();
14
+ }
15
+
16
+ /** Kill every tracked process. Safe to call multiple times. */
17
+ export function cleanupTrackedProcesses(termTimeoutMs = 5000): void {
18
+ // Snapshot targets before clearing so the delayed SIGKILL pass still
19
+ // reaches the same processes that were just signalled.
20
+ const processes = [...trackedProcesses];
21
+ for (const proc of processes) {
22
+ if (proc.exitCode !== null || proc.signalCode !== null) continue;
23
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
24
+ }
25
+ trackedProcesses.clear();
26
+
27
+ // Give SIGTERM a moment, then SIGKILL any process that has not exited.
28
+ setTimeout(() => {
29
+ for (const proc of processes) {
30
+ if (proc.exitCode !== null || proc.signalCode !== null) continue;
31
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
32
+ }
33
+ }, termTimeoutMs).unref();
34
+ }
35
+
36
+
37
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
38
+ items: TIn[],
39
+ concurrency: number,
40
+ fn: (item: TIn, index: number) => Promise<TOut>,
41
+ ): Promise<TOut[]> {
42
+ if (items.length === 0) return [];
43
+ const limit = Math.max(1, Math.min(concurrency, items.length));
44
+ const results: TOut[] = new Array(items.length);
45
+ let nextIndex = 0;
46
+ const workers = new Array(limit).fill(null).map(async () => {
47
+ while (true) {
48
+ const current = nextIndex++;
49
+ if (current >= items.length) return;
50
+ results[current] = await fn(items[current], current);
51
+ }
52
+ });
53
+ await Promise.all(workers);
54
+ return results;
55
+ }