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.
- package/README.md +43 -6
- package/dist/cli.js +4 -2
- package/dist/headless.d.ts +3 -0
- package/dist/headless.js +136 -8
- package/dist/help-text.js +3 -0
- package/dist/loader.js +33 -4
- package/dist/resources/extensions/bg-shell/index.ts +19 -2
- package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/dist/resources/extensions/bg-shell/types.ts +21 -1
- package/dist/resources/extensions/gsd/auto/session.ts +224 -0
- package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
- package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/dist/resources/extensions/gsd/auto.ts +977 -1551
- package/dist/resources/extensions/gsd/commands.ts +3 -3
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/dist/resources/extensions/gsd/export-html.ts +1001 -0
- package/dist/resources/extensions/gsd/export.ts +49 -1
- package/dist/resources/extensions/gsd/git-service.ts +6 -0
- package/dist/resources/extensions/gsd/gitignore.ts +4 -1
- package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
- package/dist/resources/extensions/gsd/index.ts +54 -1
- package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/dist/resources/extensions/gsd/preferences.ts +62 -1
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/dist/resources/extensions/gsd/prompts/system.md +1 -1
- package/dist/resources/extensions/gsd/reports.ts +510 -0
- package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/dist/resources/extensions/gsd/state.ts +30 -0
- package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/dist/resources/extensions/gsd/types.ts +38 -0
- package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/dist/resources/extensions/shared/format-utils.ts +85 -0
- package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/dist/resources/extensions/subagent/index.ts +46 -1
- package/dist/resources/extensions/subagent/isolation.ts +9 -6
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
- package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
- package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
- package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
- package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
- package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +1 -1
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +3 -1
- package/scripts/link-workspace-packages.cjs +22 -6
- package/src/resources/extensions/bg-shell/index.ts +19 -2
- package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/src/resources/extensions/bg-shell/types.ts +21 -1
- package/src/resources/extensions/gsd/auto/session.ts +224 -0
- package/src/resources/extensions/gsd/auto-budget.ts +32 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/src/resources/extensions/gsd/auto-observability.ts +74 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/src/resources/extensions/gsd/auto.ts +977 -1551
- package/src/resources/extensions/gsd/commands.ts +3 -3
- package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/src/resources/extensions/gsd/export-html.ts +1001 -0
- package/src/resources/extensions/gsd/export.ts +49 -1
- package/src/resources/extensions/gsd/git-service.ts +6 -0
- package/src/resources/extensions/gsd/gitignore.ts +4 -1
- package/src/resources/extensions/gsd/guided-flow.ts +24 -5
- package/src/resources/extensions/gsd/index.ts +54 -1
- package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/src/resources/extensions/gsd/observability-validator.ts +21 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/src/resources/extensions/gsd/preferences.ts +62 -1
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/src/resources/extensions/gsd/prompts/system.md +1 -1
- package/src/resources/extensions/gsd/reports.ts +510 -0
- package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/src/resources/extensions/gsd/state.ts +30 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/src/resources/extensions/gsd/types.ts +38 -0
- package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/src/resources/extensions/gsd/verification-gate.ts +567 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/src/resources/extensions/shared/format-utils.ts +85 -0
- package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/src/resources/extensions/subagent/index.ts +46 -1
- 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 ?
|
|
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 ?
|
|
703
|
+
const planContent = planFile ? readFileCached(planFile) : null;
|
|
682
704
|
|
|
683
705
|
if (planContent) {
|
|
684
706
|
const plan = parsePlan(planContent);
|