gsd-pi 2.26.0 → 2.27.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 (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. package/src/resources/extensions/subagent/isolation.ts +9 -6
@@ -0,0 +1,567 @@
1
+ // GSD Extension — Verification Gate
2
+ // Pure functions for discovering and running verification commands.
3
+ // Discovery order (D003): preference → task plan verify → package.json scripts.
4
+ // First non-empty source wins.
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join, basename } from "node:path";
9
+ import type { AuditWarning, RuntimeError, VerificationCheck, VerificationResult } from "./types.js";
10
+
11
+ /** Maximum bytes of stdout/stderr to retain per command (10 KB). */
12
+ const MAX_OUTPUT_BYTES = 10 * 1024;
13
+
14
+ /** Truncate a string to maxBytes, appending a marker if truncated. */
15
+ function truncate(value: string | null | undefined, maxBytes: number): string {
16
+ if (!value) return "";
17
+ if (Buffer.byteLength(value, "utf-8") <= maxBytes) return value;
18
+ // Slice conservatively then trim to last full character
19
+ const buf = Buffer.from(value, "utf-8").subarray(0, maxBytes);
20
+ return buf.toString("utf-8") + "\n…[truncated]";
21
+ }
22
+
23
+ // ─── Command Discovery ──────────────────────────────────────────────────────
24
+
25
+ export interface DiscoverCommandsOptions {
26
+ preferenceCommands?: string[];
27
+ taskPlanVerify?: string;
28
+ cwd: string;
29
+ }
30
+
31
+ export interface DiscoveredCommands {
32
+ commands: string[];
33
+ source: VerificationResult["discoverySource"];
34
+ }
35
+
36
+ /** Package.json script keys to probe, in order. */
37
+ const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"] as const;
38
+
39
+ /**
40
+ * Discover verification commands using the first-non-empty-wins strategy (D003):
41
+ * 1. Explicit preference commands
42
+ * 2. Task plan verify field (split on &&)
43
+ * 3. package.json scripts (typecheck, lint, test)
44
+ * 4. None found
45
+ */
46
+ export function discoverCommands(options: DiscoverCommandsOptions): DiscoveredCommands {
47
+ // 1. Preference commands
48
+ if (options.preferenceCommands && options.preferenceCommands.length > 0) {
49
+ const filtered = options.preferenceCommands
50
+ .map(c => c.trim())
51
+ .filter(Boolean);
52
+ if (filtered.length > 0) {
53
+ return { commands: filtered, source: "preference" };
54
+ }
55
+ }
56
+
57
+ // 2. Task plan verify field (commands are untrusted — sanitize)
58
+ if (options.taskPlanVerify && options.taskPlanVerify.trim()) {
59
+ const commands = options.taskPlanVerify
60
+ .split("&&")
61
+ .map(c => c.trim())
62
+ .filter(Boolean)
63
+ .filter(c => sanitizeCommand(c) !== null);
64
+ if (commands.length > 0) {
65
+ return { commands, source: "task-plan" };
66
+ }
67
+ }
68
+
69
+ // 3. package.json scripts
70
+ const pkgPath = join(options.cwd, "package.json");
71
+ if (existsSync(pkgPath)) {
72
+ try {
73
+ const raw = readFileSync(pkgPath, "utf-8");
74
+ const pkg = JSON.parse(raw);
75
+ if (pkg && typeof pkg === "object" && pkg.scripts && typeof pkg.scripts === "object") {
76
+ const commands: string[] = [];
77
+ for (const key of PACKAGE_SCRIPT_KEYS) {
78
+ if (typeof pkg.scripts[key] === "string") {
79
+ commands.push(`npm run ${key}`);
80
+ }
81
+ }
82
+ if (commands.length > 0) {
83
+ return { commands, source: "package-json" };
84
+ }
85
+ }
86
+ } catch {
87
+ // Malformed package.json — fall through to "none"
88
+ }
89
+ }
90
+
91
+ // 4. Nothing found
92
+ return { commands: [], source: "none" };
93
+ }
94
+
95
+ // ─── Failure Context Formatting ──────────────────────────────────────────────
96
+
97
+ /** Maximum chars of stderr to include per failed check in failure context. */
98
+ const MAX_STDERR_PER_CHECK = 2_000;
99
+
100
+ /** Maximum total chars for the combined failure context output. */
101
+ const MAX_FAILURE_CONTEXT_CHARS = 10_000;
102
+
103
+ /**
104
+ * Format failed verification checks into a prompt-injectable text block.
105
+ *
106
+ * Each failed check gets a heading with the command name and exit code,
107
+ * followed by a truncated stderr excerpt. Individual stderr is capped to
108
+ * 2 000 chars; total output is capped to 10 000 chars.
109
+ *
110
+ * Returns an empty string when all checks pass or the checks array is empty.
111
+ */
112
+ export function formatFailureContext(result: VerificationResult): string {
113
+ const failures = result.checks.filter((c) => c.exitCode !== 0);
114
+ if (failures.length === 0) return "";
115
+
116
+ const blocks: string[] = [];
117
+
118
+ for (const check of failures) {
119
+ let stderr = check.stderr ?? "";
120
+ if (stderr.length > MAX_STDERR_PER_CHECK) {
121
+ stderr = stderr.slice(0, MAX_STDERR_PER_CHECK) + "\n…[truncated]";
122
+ }
123
+
124
+ blocks.push(
125
+ `### ❌ \`${check.command}\` (exit code ${check.exitCode})\n\`\`\`stderr\n${stderr}\n\`\`\``,
126
+ );
127
+ }
128
+
129
+ let body = blocks.join("\n\n");
130
+ const header = "## Verification Failures\n\n";
131
+
132
+ if (header.length + body.length > MAX_FAILURE_CONTEXT_CHARS) {
133
+ body =
134
+ body.slice(0, MAX_FAILURE_CONTEXT_CHARS - header.length) +
135
+ "\n\n…[remaining failures truncated]";
136
+ }
137
+
138
+ return header + body;
139
+ }
140
+
141
+ // ─── Gate Execution ─────────────────────────────────────────────────────────
142
+
143
+ /** Characters that indicate shell injection when found in a command string. */
144
+ const SHELL_INJECTION_PATTERN = /[;|`]|\$\(/;
145
+
146
+ /**
147
+ * Validate a command string for obvious shell injection patterns.
148
+ * Returns the command unchanged if safe, or null if suspicious.
149
+ */
150
+ function sanitizeCommand(cmd: string): string | null {
151
+ if (SHELL_INJECTION_PATTERN.test(cmd)) return null;
152
+ return cmd;
153
+ }
154
+
155
+ /** Default timeout for verification commands (ms). */
156
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
157
+
158
+ export interface RunVerificationGateOptions {
159
+ basePath: string;
160
+ unitId: string;
161
+ cwd: string;
162
+ preferenceCommands?: string[];
163
+ taskPlanVerify?: string;
164
+ /** Per-command timeout in ms. Defaults to 120 000 (2 minutes). */
165
+ commandTimeoutMs?: number;
166
+ }
167
+
168
+ /**
169
+ * Run the verification gate: discover commands, execute each via spawnSync,
170
+ * and return a structured result.
171
+ *
172
+ * - All commands run sequentially regardless of individual pass/fail.
173
+ * - `passed` is true when every command exits 0 (or no commands are discovered).
174
+ * - stdout/stderr per command are truncated to 10 KB.
175
+ */
176
+ export function runVerificationGate(options: RunVerificationGateOptions): VerificationResult {
177
+ const timestamp = Date.now();
178
+
179
+ const { commands, source } = discoverCommands({
180
+ preferenceCommands: options.preferenceCommands,
181
+ taskPlanVerify: options.taskPlanVerify,
182
+ cwd: options.cwd,
183
+ });
184
+
185
+ if (commands.length === 0) {
186
+ return {
187
+ passed: true,
188
+ checks: [],
189
+ discoverySource: source,
190
+ timestamp,
191
+ };
192
+ }
193
+
194
+ const checks: VerificationCheck[] = [];
195
+
196
+ for (const command of commands) {
197
+ const start = Date.now();
198
+ const result = spawnSync(command, {
199
+ shell: true,
200
+ cwd: options.cwd,
201
+ stdio: "pipe",
202
+ encoding: "utf-8",
203
+ timeout: options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
204
+ });
205
+ const durationMs = Date.now() - start;
206
+
207
+ let exitCode: number;
208
+ let stderr: string;
209
+
210
+ if (result.error) {
211
+ // Command not found or spawn failure
212
+ exitCode = 127;
213
+ stderr = truncate(
214
+ (result.stderr || "") + "\n" + (result.error as Error).message,
215
+ MAX_OUTPUT_BYTES,
216
+ );
217
+ } else {
218
+ // status is null when killed by signal — treat as failure
219
+ exitCode = result.status ?? 1;
220
+ stderr = truncate(result.stderr, MAX_OUTPUT_BYTES);
221
+ }
222
+
223
+ checks.push({
224
+ command,
225
+ exitCode,
226
+ stdout: truncate(result.stdout, MAX_OUTPUT_BYTES),
227
+ stderr,
228
+ durationMs,
229
+ });
230
+ }
231
+
232
+ return {
233
+ passed: checks.every(c => c.exitCode === 0),
234
+ checks,
235
+ discoverySource: source,
236
+ timestamp,
237
+ };
238
+ }
239
+
240
+ // ─── Runtime Error Capture ──────────────────────────────────────────────────
241
+
242
+ /** Maximum characters of browser console text to retain per entry. */
243
+ const MAX_BROWSER_TEXT_CHARS = 500;
244
+
245
+ /** Fatal signals that indicate a crash regardless of other status fields. */
246
+ const FATAL_SIGNALS = new Set(["SIGABRT", "SIGSEGV", "SIGBUS"]);
247
+
248
+ /**
249
+ * Injectable dependencies for captureRuntimeErrors.
250
+ * When omitted the function uses dynamic import() to access
251
+ * bg-shell's processes Map and browser-tools' getConsoleLogs().
252
+ * Provide overrides in tests to avoid module mocking.
253
+ */
254
+ export interface CaptureRuntimeErrorsOptions {
255
+ getProcesses?: () => Map<string, unknown>;
256
+ getConsoleLogs?: () => Array<{ type: string; text: string; timestamp: number; url: string }>;
257
+ }
258
+
259
+ /**
260
+ * Scan bg-shell processes and browser console logs for runtime errors.
261
+ *
262
+ * Severity classification follows D004:
263
+ * - bg-shell status "crashed" → blocking crash
264
+ * - bg-shell !alive && exitCode !== 0 && exitCode !== null → blocking crash
265
+ * - bg-shell signal SIGABRT/SIGSEGV/SIGBUS → blocking crash
266
+ * - Browser console error with "Unhandled"/"UnhandledRejection" → blocking crash
267
+ * - Browser console error (general) → non-blocking error
268
+ * - Browser console warning with deprecation text → non-blocking warning
269
+ * - bg-shell alive process with recentErrors → non-blocking error
270
+ *
271
+ * Returns RuntimeError[] — empty when both sources are unavailable.
272
+ */
273
+ export async function captureRuntimeErrors(
274
+ options?: CaptureRuntimeErrorsOptions,
275
+ ): Promise<RuntimeError[]> {
276
+ const errors: RuntimeError[] = [];
277
+
278
+ // ── bg-shell scan ─────────────────────────────────────────────────────
279
+ try {
280
+ let processes: Map<string, unknown>;
281
+ if (options?.getProcesses) {
282
+ processes = options.getProcesses();
283
+ } else {
284
+ const mod = await import("../bg-shell/process-manager.js");
285
+ processes = mod.processes;
286
+ }
287
+
288
+ for (const [id, raw] of processes) {
289
+ const proc = raw as {
290
+ id: string;
291
+ label?: string;
292
+ status?: string;
293
+ alive?: boolean;
294
+ exitCode?: number | null;
295
+ signal?: string | null;
296
+ recentErrors?: string[];
297
+ };
298
+
299
+ const name = proc.label || proc.id || id;
300
+
301
+ // Check for fatal signal first (applies regardless of alive/status)
302
+ if (proc.signal && FATAL_SIGNALS.has(proc.signal)) {
303
+ errors.push({
304
+ source: "bg-shell",
305
+ severity: "crash",
306
+ message: buildBgShellMessage(name, proc.exitCode, proc.signal, proc.recentErrors),
307
+ blocking: true,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ // Crashed status
313
+ if (proc.status === "crashed") {
314
+ errors.push({
315
+ source: "bg-shell",
316
+ severity: "crash",
317
+ message: buildBgShellMessage(name, proc.exitCode, proc.signal, proc.recentErrors),
318
+ blocking: true,
319
+ });
320
+ continue;
321
+ }
322
+
323
+ // Non-zero exit on dead process
324
+ if (
325
+ !proc.alive &&
326
+ proc.exitCode !== 0 &&
327
+ proc.exitCode !== null &&
328
+ proc.exitCode !== undefined
329
+ ) {
330
+ errors.push({
331
+ source: "bg-shell",
332
+ severity: "crash",
333
+ message: buildBgShellMessage(name, proc.exitCode, proc.signal, proc.recentErrors),
334
+ blocking: true,
335
+ });
336
+ continue;
337
+ }
338
+
339
+ // Alive process with recent errors — non-blocking
340
+ if (proc.alive && proc.recentErrors && proc.recentErrors.length > 0) {
341
+ const snippet = proc.recentErrors.slice(0, 3).join("; ");
342
+ errors.push({
343
+ source: "bg-shell",
344
+ severity: "error",
345
+ message: `[${name}] recent errors: ${snippet}`,
346
+ blocking: false,
347
+ });
348
+ }
349
+ }
350
+ } catch {
351
+ // bg-shell not available — skip silently
352
+ }
353
+
354
+ // ── browser console scan ──────────────────────────────────────────────
355
+ try {
356
+ let logs: Array<{ type: string; text: string; timestamp: number; url: string }>;
357
+ if (options?.getConsoleLogs) {
358
+ logs = options.getConsoleLogs();
359
+ } else {
360
+ const mod = await import("../browser-tools/state.js");
361
+ logs = mod.getConsoleLogs();
362
+ }
363
+
364
+ for (const entry of logs) {
365
+ const text =
366
+ entry.text.length > MAX_BROWSER_TEXT_CHARS
367
+ ? entry.text.slice(0, MAX_BROWSER_TEXT_CHARS) + "…[truncated]"
368
+ : entry.text;
369
+
370
+ if (entry.type === "error") {
371
+ // Unhandled rejection / unhandled error → blocking crash
372
+ if (/unhandled/i.test(entry.text)) {
373
+ errors.push({
374
+ source: "browser",
375
+ severity: "crash",
376
+ message: text,
377
+ blocking: true,
378
+ });
379
+ } else {
380
+ // General console.error → non-blocking error
381
+ errors.push({
382
+ source: "browser",
383
+ severity: "error",
384
+ message: text,
385
+ blocking: false,
386
+ });
387
+ }
388
+ } else if (entry.type === "warning" && /deprecated/i.test(entry.text)) {
389
+ // Deprecation warning → non-blocking warning
390
+ errors.push({
391
+ source: "browser",
392
+ severity: "warning",
393
+ message: text,
394
+ blocking: false,
395
+ });
396
+ }
397
+ // Non-deprecation warnings are intentionally ignored
398
+ }
399
+ } catch {
400
+ // browser-tools not available — skip silently
401
+ }
402
+
403
+ return errors;
404
+ }
405
+
406
+ /** Build a human-readable message for a bg-shell process error. */
407
+ function buildBgShellMessage(
408
+ name: string,
409
+ exitCode: number | null | undefined,
410
+ signal: string | null | undefined,
411
+ recentErrors: string[] | undefined,
412
+ ): string {
413
+ const parts: string[] = [`[${name}]`];
414
+ if (signal) parts.push(`signal=${signal}`);
415
+ if (exitCode !== null && exitCode !== undefined) parts.push(`exitCode=${exitCode}`);
416
+ if (recentErrors && recentErrors.length > 0) {
417
+ const snippet = recentErrors.slice(0, 3).join("; ");
418
+ parts.push(`errors: ${snippet}`);
419
+ }
420
+ return parts.join(" ");
421
+ }
422
+
423
+ // ─── Dependency Audit ───────────────────────────────────────────────────────
424
+
425
+ /** Top-level dependency files that trigger an audit when changed. */
426
+ const DEPENDENCY_FILES = new Set([
427
+ "package.json",
428
+ "package-lock.json",
429
+ "pnpm-lock.yaml",
430
+ "yarn.lock",
431
+ "bun.lockb",
432
+ ]);
433
+
434
+ /**
435
+ * Injectable dependencies for runDependencyAudit (D023 pattern).
436
+ * When omitted the function uses real git/npm via spawnSync.
437
+ * Provide overrides in tests to avoid real git repos and npm registries.
438
+ */
439
+ export interface DependencyAuditOptions {
440
+ gitDiff?: (cwd: string) => string[];
441
+ npmAudit?: (cwd: string) => { stdout: string; exitCode: number };
442
+ }
443
+
444
+ /**
445
+ * Default gitDiff: runs `git diff --name-only HEAD` and returns file paths.
446
+ * Returns empty array on any failure (non-git dir, git not found, etc.).
447
+ */
448
+ function defaultGitDiff(cwd: string): string[] {
449
+ try {
450
+ const result = spawnSync("git", ["diff", "--name-only", "HEAD"], {
451
+ cwd,
452
+ encoding: "utf-8",
453
+ timeout: 10_000,
454
+ });
455
+ if (result.status !== 0 || !result.stdout) return [];
456
+ return result.stdout.trim().split("\n").filter(Boolean);
457
+ } catch {
458
+ return [];
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Default npmAudit: runs `npm audit --audit-level=moderate --json`.
464
+ * Returns { stdout, exitCode }. Non-zero exit is expected when vulnerabilities exist.
465
+ */
466
+ function defaultNpmAudit(cwd: string): { stdout: string; exitCode: number } {
467
+ const result = spawnSync("npm", ["audit", "--audit-level=moderate", "--json"], {
468
+ cwd,
469
+ encoding: "utf-8",
470
+ timeout: 60_000,
471
+ });
472
+ return {
473
+ stdout: result.stdout ?? "",
474
+ exitCode: result.status ?? 1,
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Detect dependency file changes and run npm audit if changes are found.
480
+ *
481
+ * - Calls gitDiff to get changed files, checks if any are top-level dependency files
482
+ * - If no dependency files changed, returns []
483
+ * - Runs npmAudit and parses JSON output into AuditWarning[]
484
+ * - Never throws — all errors return []
485
+ * - Non-zero npm audit exit code is expected (vulnerabilities found), not an error
486
+ */
487
+ export function runDependencyAudit(
488
+ cwd: string,
489
+ options?: DependencyAuditOptions,
490
+ ): AuditWarning[] {
491
+ try {
492
+ const gitDiff = options?.gitDiff ?? defaultGitDiff;
493
+ const npmAudit = options?.npmAudit ?? defaultNpmAudit;
494
+
495
+ // Get changed files and check for top-level dependency file matches
496
+ const changedFiles = gitDiff(cwd);
497
+ const hasDependencyChange = changedFiles.some((filePath) => {
498
+ const name = basename(filePath);
499
+ // Only match top-level files: the path must equal just the filename
500
+ // (no directory separators) to be considered top-level
501
+ return DEPENDENCY_FILES.has(name) && filePath === name;
502
+ });
503
+
504
+ if (!hasDependencyChange) return [];
505
+
506
+ // Run npm audit
507
+ const auditResult = npmAudit(cwd);
508
+
509
+ // Parse JSON output — npm audit exits non-zero when vulnerabilities exist
510
+ let parsed: Record<string, unknown>;
511
+ try {
512
+ parsed = JSON.parse(auditResult.stdout);
513
+ } catch {
514
+ return [];
515
+ }
516
+
517
+ // Extract vulnerabilities from the parsed output
518
+ const vulnerabilities = parsed.vulnerabilities;
519
+ if (!vulnerabilities || typeof vulnerabilities !== "object") return [];
520
+
521
+ const warnings: AuditWarning[] = [];
522
+ for (const [name, raw] of Object.entries(vulnerabilities as Record<string, unknown>)) {
523
+ const vuln = raw as {
524
+ severity?: string;
525
+ fixAvailable?: boolean;
526
+ via?: unknown[];
527
+ };
528
+ if (!vuln || typeof vuln !== "object") continue;
529
+
530
+ const severity = vuln.severity;
531
+ if (
532
+ severity !== "low" &&
533
+ severity !== "moderate" &&
534
+ severity !== "high" &&
535
+ severity !== "critical"
536
+ ) {
537
+ continue;
538
+ }
539
+
540
+ // Find the first `via` entry that's an object (not a string reference)
541
+ let title = name;
542
+ let url = "";
543
+ if (Array.isArray(vuln.via)) {
544
+ for (const entry of vuln.via) {
545
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
546
+ const obj = entry as { title?: string; url?: string };
547
+ if (obj.title) title = obj.title;
548
+ if (obj.url) url = obj.url;
549
+ break;
550
+ }
551
+ }
552
+ }
553
+
554
+ warnings.push({
555
+ name,
556
+ severity: severity as AuditWarning["severity"],
557
+ title,
558
+ url,
559
+ fixAvailable: vuln.fixAvailable === true,
560
+ });
561
+ }
562
+
563
+ return warnings;
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
@@ -440,7 +440,6 @@ async function loadChangelogAndVerifications(basePath: string, milestones: Visua
440
440
 
441
441
  let mtime = 0;
442
442
  try {
443
- const { statSync } = await import('node:fs');
444
443
  mtime = statSync(summaryFile).mtimeMs;
445
444
  } catch {
446
445
  continue;
@@ -648,6 +647,29 @@ function loadDiscussionState(
648
647
  return states;
649
648
  }
650
649
 
650
+ // ─── File Fingerprint Cache ───────────────────────────────────────────────────
651
+
652
+ /**
653
+ * Mtime-based cache for parsed file contents. Avoids re-reading and re-parsing
654
+ * roadmap/plan files whose mtime hasn't changed since the last load.
655
+ */
656
+ const fileContentCache = new Map<string, { mtime: number; content: string }>();
657
+
658
+ function readFileCached(filePath: string): string | null {
659
+ try {
660
+ const mtime = statSync(filePath).mtimeMs;
661
+ const cached = fileContentCache.get(filePath);
662
+ if (cached && cached.mtime === mtime) {
663
+ return cached.content;
664
+ }
665
+ const content = readFileSync(filePath, 'utf-8');
666
+ fileContentCache.set(filePath, { mtime, content });
667
+ return content;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
672
+
651
673
  // ─── Loader ───────────────────────────────────────────────────────────────────
652
674
 
653
675
  export async function loadVisualizerData(basePath: string): Promise<VisualizerData> {
@@ -664,7 +686,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
664
686
  const slices: VisualizerSlice[] = [];
665
687
 
666
688
  const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP');
667
- const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
689
+ const roadmapContent = roadmapFile ? readFileCached(roadmapFile) : null;
668
690
 
669
691
  if (roadmapContent) {
670
692
  const roadmap = parseRoadmap(roadmapContent);
@@ -678,7 +700,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
678
700
 
679
701
  if (isActiveSlice) {
680
702
  const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
681
- const planContent = planFile ? await loadFile(planFile) : null;
703
+ const planContent = planFile ? readFileCached(planFile) : null;
682
704
 
683
705
  if (planContent) {
684
706
  const plan = parsePlan(planContent);