gsd-pi 2.76.0-dev.97807402 → 2.76.0-dev.97f5583d9

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 (100) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +28 -1
  2. package/dist/resources/extensions/gsd/auto/session.js +12 -0
  3. package/dist/resources/extensions/gsd/auto-dispatch.js +16 -3
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +24 -1
  5. package/dist/resources/extensions/gsd/auto-prompts.js +14 -0
  6. package/dist/resources/extensions/gsd/auto-worktree.js +21 -5
  7. package/dist/resources/extensions/gsd/auto.js +42 -10
  8. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +11 -1
  9. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +22 -1
  10. package/dist/resources/extensions/gsd/clean-root-preflight.js +93 -0
  11. package/dist/resources/extensions/gsd/safety/evidence-collector.js +96 -0
  12. package/dist/resources/extensions/gsd/safety/file-change-validator.js +3 -1
  13. package/dist/resources/extensions/gsd/safety/safety-harness.js +1 -1
  14. package/dist/resources/extensions/gsd/uok/plan-v2.js +20 -3
  15. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  44. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  45. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/packages/mcp-server/dist/server.d.ts +7 -0
  51. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  52. package/packages/mcp-server/dist/server.js +23 -3
  53. package/packages/mcp-server/dist/server.js.map +1 -1
  54. package/packages/mcp-server/src/mcp-server.test.ts +30 -0
  55. package/packages/mcp-server/src/server.ts +43 -9
  56. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  57. package/packages/pi-ai/dist/providers/anthropic-auth.test.js +1 -1
  58. package/packages/pi-ai/dist/providers/anthropic-auth.test.js.map +1 -1
  59. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -1
  60. package/packages/pi-ai/dist/providers/anthropic-shared.js +25 -4
  61. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -1
  62. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  63. package/packages/pi-ai/dist/providers/anthropic.js +8 -3
  64. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  65. package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts +2 -0
  66. package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts.map +1 -0
  67. package/packages/pi-ai/dist/providers/minimax-tool-name.test.js +80 -0
  68. package/packages/pi-ai/dist/providers/minimax-tool-name.test.js.map +1 -0
  69. package/packages/pi-ai/src/providers/anthropic-auth.test.ts +1 -1
  70. package/packages/pi-ai/src/providers/anthropic-shared.ts +23 -4
  71. package/packages/pi-ai/src/providers/anthropic.ts +9 -3
  72. package/packages/pi-ai/src/providers/minimax-tool-name.test.ts +98 -0
  73. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  74. package/src/resources/extensions/gsd/auto/loop-deps.ts +13 -0
  75. package/src/resources/extensions/gsd/auto/phases.ts +52 -1
  76. package/src/resources/extensions/gsd/auto/session.ts +22 -0
  77. package/src/resources/extensions/gsd/auto-dispatch.ts +16 -3
  78. package/src/resources/extensions/gsd/auto-post-unit.ts +28 -1
  79. package/src/resources/extensions/gsd/auto-prompts.ts +28 -1
  80. package/src/resources/extensions/gsd/auto-worktree.ts +28 -11
  81. package/src/resources/extensions/gsd/auto.ts +46 -10
  82. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -1
  83. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +22 -1
  84. package/src/resources/extensions/gsd/clean-root-preflight.ts +111 -0
  85. package/src/resources/extensions/gsd/safety/evidence-collector.ts +119 -0
  86. package/src/resources/extensions/gsd/safety/file-change-validator.ts +3 -1
  87. package/src/resources/extensions/gsd/safety/safety-harness.ts +3 -0
  88. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +3 -1
  89. package/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts +12 -0
  90. package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +186 -0
  91. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +2 -0
  92. package/src/resources/extensions/gsd/tests/double-merge-guard.test.ts +1 -1
  93. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +2 -0
  94. package/src/resources/extensions/gsd/tests/pre-exec-gate-loop.test.ts +272 -0
  95. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +205 -0
  96. package/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts +23 -0
  97. package/src/resources/extensions/gsd/uok/plan-v2.ts +26 -3
  98. package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
  99. /package/dist/web/standalone/.next/static/{pI48IF3dgfs0CBrYi2bh_ → lLdDRDspgYzfz0bJAmUSz}/_buildManifest.js +0 -0
  100. /package/dist/web/standalone/.next/static/{pI48IF3dgfs0CBrYi2bh_ → lLdDRDspgYzfz0bJAmUSz}/_ssgManifest.js +0 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * pre-exec-gate-loop.test.ts — Regression tests for #4551.
3
+ *
4
+ * Verifies that when a pre-execution gate fails on a plan-slice unit:
5
+ * 1. `s.lastPreExecFailure` is populated on the AutoSession with the blocking
6
+ * findings and a verdict excerpt.
7
+ * 2. The `planning → plan-slice` dispatch rule reads that field and injects a
8
+ * "Fix these specific issues" section into the prompt.
9
+ * 3. The field is cleared (consumed) after the prompt is built so that stale
10
+ * context does not bleed into an unrelated future plan-slice run.
11
+ * 4. When the failure belongs to a *different* unit ID, the dispatch rule
12
+ * does NOT inject the stale context into the prompt.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+
21
+ import { AutoSession } from "../auto/session.ts";
22
+ import { resolveDispatch } from "../auto-dispatch.ts";
23
+ import type { DispatchContext } from "../auto-dispatch.ts";
24
+ import { buildPlanSlicePrompt } from "../auto-prompts.ts";
25
+ import {
26
+ openDatabase,
27
+ closeDatabase,
28
+ insertMilestone,
29
+ insertSlice,
30
+ insertTask,
31
+ } from "../gsd-db.ts";
32
+ import { deriveStateFromDb } from "../state.ts";
33
+ import { _clearGsdRootCache } from "../paths.ts";
34
+ import { invalidateAllCaches } from "../cache.ts";
35
+
36
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ function makeTempBase(): string {
39
+ const base = mkdtempSync(join(tmpdir(), "gsd-4551-"));
40
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
41
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
42
+ return base;
43
+ }
44
+
45
+ function seedPlanningState(base: string): void {
46
+ openDatabase(join(base, ".gsd", "gsd.db"));
47
+ insertMilestone({ id: "M001", title: "Test Milestone", status: "active" });
48
+ insertSlice({
49
+ id: "S01",
50
+ milestoneId: "M001",
51
+ title: "Core Slice",
52
+ status: "pending",
53
+ risk: "medium",
54
+ depends: [],
55
+ demo: "demo",
56
+ sequence: 1,
57
+ isSketch: false,
58
+ });
59
+ // Write minimal ROADMAP so state derivation doesn't error
60
+ writeFileSync(
61
+ join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
62
+ "# Roadmap\n",
63
+ );
64
+ }
65
+
66
+ function cleanup(base: string, originalCwd: string): void {
67
+ try { closeDatabase(); } catch { /* noop */ }
68
+ try { process.chdir(originalCwd); } catch { /* noop */ }
69
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
70
+ }
71
+
72
+ // ─── Tests ────────────────────────────────────────────────────────────────────
73
+
74
+ test("#4551: AutoSession.lastPreExecFailure defaults to null", () => {
75
+ const s = new AutoSession();
76
+ assert.equal(s.lastPreExecFailure, null, "lastPreExecFailure must start null");
77
+ });
78
+
79
+ test("#4551: AutoSession.reset() clears lastPreExecFailure", () => {
80
+ const s = new AutoSession();
81
+ s.lastPreExecFailure = {
82
+ unitId: "M001/S01",
83
+ blockingFindings: ["[file] src/foo.ts: file not found"],
84
+ verdictExcerpt: "status=fail; 1 blocking issue detected",
85
+ };
86
+ s.reset();
87
+ assert.equal(s.lastPreExecFailure, null, "reset() must clear lastPreExecFailure");
88
+ });
89
+
90
+ test("#4551: buildPlanSlicePrompt injects fix section when priorPreExecFailure provided", async (t) => {
91
+ const originalCwd = process.cwd();
92
+ const base = makeTempBase();
93
+ t.after(() => cleanup(base, originalCwd));
94
+
95
+ seedPlanningState(base);
96
+ process.chdir(base);
97
+ _clearGsdRootCache();
98
+ invalidateAllCaches();
99
+
100
+ const prompt = await buildPlanSlicePrompt(
101
+ "M001", "Test Milestone", "S01", "Core Slice", base,
102
+ undefined,
103
+ {
104
+ priorPreExecFailure: {
105
+ blockingFindings: [
106
+ "[file] src/utils/helper.ts: file not found",
107
+ "[package] nonexistent-pkg: package not found on npm",
108
+ ],
109
+ verdictExcerpt: "status=fail; 2 blocking issues detected",
110
+ },
111
+ },
112
+ );
113
+
114
+ assert.ok(
115
+ prompt.includes("Fix these specific issues from the prior pre-exec check"),
116
+ "prompt must contain the fix section heading",
117
+ );
118
+ assert.ok(
119
+ prompt.includes("src/utils/helper.ts: file not found"),
120
+ "prompt must include the specific file finding",
121
+ );
122
+ assert.ok(
123
+ prompt.includes("nonexistent-pkg: package not found on npm"),
124
+ "prompt must include the specific package finding",
125
+ );
126
+ assert.ok(
127
+ prompt.includes("status=fail; 2 blocking issues detected"),
128
+ "prompt must include the verdict excerpt",
129
+ );
130
+ });
131
+
132
+ test("#4551: buildPlanSlicePrompt with no priorPreExecFailure does NOT include fix section", async (t) => {
133
+ const originalCwd = process.cwd();
134
+ const base = makeTempBase();
135
+ t.after(() => cleanup(base, originalCwd));
136
+
137
+ seedPlanningState(base);
138
+ process.chdir(base);
139
+ _clearGsdRootCache();
140
+ invalidateAllCaches();
141
+
142
+ const prompt = await buildPlanSlicePrompt(
143
+ "M001", "Test Milestone", "S01", "Core Slice", base,
144
+ undefined,
145
+ { /* no priorPreExecFailure */ },
146
+ );
147
+
148
+ assert.ok(
149
+ !prompt.includes("Fix these specific issues from the prior pre-exec check"),
150
+ "prompt must NOT include the fix section when no failure context is given",
151
+ );
152
+ });
153
+
154
+ test("#4551: dispatch rule injects failure context and clears session field", async (t) => {
155
+ const originalCwd = process.cwd();
156
+ const base = makeTempBase();
157
+ t.after(() => cleanup(base, originalCwd));
158
+
159
+ seedPlanningState(base);
160
+ // Write a RESEARCH file so the dispatch rule skips research-slice and reaches
161
+ // plan-slice (which is the phase we're testing).
162
+ writeFileSync(
163
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
164
+ "# Research\n",
165
+ );
166
+ process.chdir(base);
167
+ _clearGsdRootCache();
168
+ invalidateAllCaches();
169
+
170
+ const state = await deriveStateFromDb(base);
171
+ assert.equal(state.phase, "planning", "state must be in planning phase");
172
+
173
+ const session = new AutoSession();
174
+ session.basePath = base;
175
+ session.active = true;
176
+ session.lastPreExecFailure = {
177
+ unitId: "M001/S01",
178
+ blockingFindings: ["[file] src/missing.ts: file not found"],
179
+ verdictExcerpt: "status=fail; 1 blocking issue detected",
180
+ };
181
+
182
+ const ctx: DispatchContext = {
183
+ basePath: base,
184
+ mid: "M001",
185
+ midTitle: "Test Milestone",
186
+ state,
187
+ prefs: { phases: { reassess_after_slice: false, skip_research: true } } as any,
188
+ session,
189
+ };
190
+
191
+ const result = await resolveDispatch(ctx);
192
+ assert.equal(result.action, "dispatch", "must dispatch a unit");
193
+ if (result.action !== "dispatch") throw new Error("unreachable");
194
+ assert.equal(result.unitType, "plan-slice", "must be a plan-slice unit");
195
+
196
+ // The fix section must appear in the prompt
197
+ assert.ok(
198
+ result.prompt.includes("Fix these specific issues from the prior pre-exec check"),
199
+ "dispatched prompt must include the fix section",
200
+ );
201
+ assert.ok(
202
+ result.prompt.includes("src/missing.ts: file not found"),
203
+ "dispatched prompt must include the specific blocking finding",
204
+ );
205
+
206
+ // Field must be cleared after consumption
207
+ assert.equal(
208
+ session.lastPreExecFailure,
209
+ null,
210
+ "lastPreExecFailure must be cleared after being consumed by the dispatch rule",
211
+ );
212
+ });
213
+
214
+ test("#4551: dispatch rule does NOT inject stale failure for a different slice", async (t) => {
215
+ const originalCwd = process.cwd();
216
+ const base = makeTempBase();
217
+ t.after(() => cleanup(base, originalCwd));
218
+
219
+ seedPlanningState(base);
220
+ // Write a RESEARCH file so dispatch reaches plan-slice, making the assertion
221
+ // about the prompt meaningful (we can check it's a plan-slice prompt without
222
+ // the fix section rather than a research-slice prompt without it).
223
+ writeFileSync(
224
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
225
+ "# Research\n",
226
+ );
227
+ process.chdir(base);
228
+ _clearGsdRootCache();
229
+ invalidateAllCaches();
230
+
231
+ const state = await deriveStateFromDb(base);
232
+
233
+ const session = new AutoSession();
234
+ session.basePath = base;
235
+ session.active = true;
236
+ // Failure belongs to a different slice (S02), not the active one (S01)
237
+ session.lastPreExecFailure = {
238
+ unitId: "M001/S02",
239
+ blockingFindings: ["[file] src/other.ts: file not found"],
240
+ verdictExcerpt: "status=fail; 1 blocking issue detected",
241
+ };
242
+
243
+ const ctx: DispatchContext = {
244
+ basePath: base,
245
+ mid: "M001",
246
+ midTitle: "Test Milestone",
247
+ state,
248
+ prefs: { phases: { reassess_after_slice: false, skip_research: true } } as any,
249
+ session,
250
+ };
251
+
252
+ const result = await resolveDispatch(ctx);
253
+ assert.equal(result.action, "dispatch");
254
+ if (result.action !== "dispatch") throw new Error("unreachable");
255
+
256
+ // The stale fix section must NOT appear
257
+ assert.ok(
258
+ !result.prompt.includes("Fix these specific issues from the prior pre-exec check"),
259
+ "prompt must NOT include fix section for a mismatched unit ID",
260
+ );
261
+ assert.ok(
262
+ !result.prompt.includes("src/other.ts"),
263
+ "prompt must NOT include findings from a different slice",
264
+ );
265
+
266
+ // Field must remain untouched (not consumed)
267
+ assert.notEqual(
268
+ session.lastPreExecFailure,
269
+ null,
270
+ "lastPreExecFailure must NOT be cleared when unit IDs don't match",
271
+ );
272
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Regression tests for three false-positive sources in the safety harness.
3
+ * Issue #4385
4
+ *
5
+ * Bug 1: Hardcoded BASH_READ_ONLY_RE — new legitimate commands blocked
6
+ * Bug 2: Non-persisted evidence — session restart causes false positive on resume
7
+ * Bug 3: git diff HEAD~1 scope check — fails on initial commits / shallow clones
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { execFileSync } from "node:child_process";
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { shouldBlockQueueExecution } from "../bootstrap/write-gate.ts";
18
+ import {
19
+ resetEvidence,
20
+ recordToolCall,
21
+ getEvidence,
22
+ saveEvidenceToDisk,
23
+ loadEvidenceFromDisk,
24
+ } from "../safety/evidence-collector.ts";
25
+ import { validateFileChanges } from "../safety/file-change-validator.ts";
26
+
27
+ // ─── Bug 1: Hardcoded Bash allowlist ────────────────────────────────────────
28
+
29
+ test("safety-harness-bug1: npm commands are not blocked during queue mode", () => {
30
+ const r = shouldBlockQueueExecution("bash", "npm run test", true);
31
+ assert.strictEqual(r.block, false, "npm run test must be read-only-safe");
32
+ });
33
+
34
+ test("safety-harness-bug1: npx commands are not blocked during queue mode", () => {
35
+ const r = shouldBlockQueueExecution("bash", "npx tsc --noEmit", true);
36
+ assert.strictEqual(r.block, false, "npx tsc --noEmit must pass");
37
+ });
38
+
39
+ test("safety-harness-bug1: tsx commands are not blocked during queue mode", () => {
40
+ const r = shouldBlockQueueExecution("bash", "tsx src/index.ts", true);
41
+ assert.strictEqual(
42
+ r.block,
43
+ false,
44
+ "tsx (TypeScript runner — read-only investigative) must pass",
45
+ );
46
+ });
47
+
48
+ test("safety-harness-bug1: node --print commands are not blocked during queue mode", () => {
49
+ const r = shouldBlockQueueExecution("bash", "node --print 'process.version'", true);
50
+ assert.strictEqual(r.block, false, "node --print must pass");
51
+ });
52
+
53
+ test("safety-harness-bug1: python read-only invocations are not blocked during queue mode", () => {
54
+ const r = shouldBlockQueueExecution("bash", "python -c 'import sys; print(sys.version)'", true);
55
+ assert.strictEqual(r.block, false, "python -c read-only must pass");
56
+ });
57
+
58
+ test("safety-harness-bug1: jq read-only command is not blocked during queue mode", () => {
59
+ const r = shouldBlockQueueExecution("bash", "jq '.version' package.json", true);
60
+ assert.strictEqual(r.block, false, "jq (read-only JSON query) must pass");
61
+ });
62
+
63
+ test("safety-harness-bug1: destructive commands are still blocked during queue mode", () => {
64
+ const r = shouldBlockQueueExecution("bash", "rm -rf dist/", true);
65
+ assert.strictEqual(r.block, true, "rm -rf must still be blocked");
66
+ });
67
+
68
+ // ─── Bug 2: Non-persisted evidence ──────────────────────────────────────────
69
+
70
+ test("safety-harness-bug2: evidence survives save/load round-trip (simulates session restart)", (t) => {
71
+ const base = mkdtempSync(join(tmpdir(), "gsd-evidence-persist-"));
72
+ t.after(() => rmSync(base, { recursive: true, force: true }));
73
+
74
+ resetEvidence();
75
+
76
+ // Simulate bash tool calls during unit execution
77
+ recordToolCall("tc-001", "Bash", { command: "npm run test:unit" });
78
+ recordToolCall("tc-002", "Bash", { command: "npx tsc --noEmit" });
79
+ recordToolCall("tc-003", "Write", { file_path: "src/foo.ts" });
80
+
81
+ const before = getEvidence();
82
+ assert.equal(before.length, 3, "three entries before save");
83
+
84
+ // Persist to disk
85
+ saveEvidenceToDisk(base, "M001", "S001", "T001");
86
+
87
+ // Simulate session restart: module-level array reset
88
+ resetEvidence();
89
+ assert.equal(getEvidence().length, 0, "in-memory cleared after reset");
90
+
91
+ // Resume: load from disk
92
+ loadEvidenceFromDisk(base, "M001", "S001", "T001");
93
+
94
+ const after = getEvidence();
95
+ assert.equal(after.length, 3, "evidence restored from disk after simulated restart");
96
+
97
+ const bashEntries = after.filter((e) => e.kind === "bash");
98
+ assert.equal(bashEntries.length, 2, "both bash entries restored");
99
+
100
+ const writeEntries = after.filter((e) => e.kind === "write");
101
+ assert.equal(writeEntries.length, 1, "write entry restored");
102
+ });
103
+
104
+ test("safety-harness-bug2: loadEvidenceFromDisk returns empty array when no file exists (fresh unit)", (t) => {
105
+ const base = mkdtempSync(join(tmpdir(), "gsd-evidence-nopersist-"));
106
+ t.after(() => rmSync(base, { recursive: true, force: true }));
107
+
108
+ resetEvidence();
109
+ loadEvidenceFromDisk(base, "M001", "S001", "T001");
110
+ assert.equal(getEvidence().length, 0, "no evidence on fresh unit is correct — not a false positive");
111
+ });
112
+
113
+ // ─── Bug 3: git diff HEAD~1 scope check ─────────────────────────────────────
114
+
115
+ test("safety-harness-bug3: validateFileChanges works on initial commit (no HEAD~1)", (t) => {
116
+ const base = mkdtempSync(join(tmpdir(), "gsd-initial-commit-"));
117
+ t.after(() => rmSync(base, { recursive: true, force: true }));
118
+
119
+ execFileSync("git", ["init"], { cwd: base });
120
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: base });
121
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd: base });
122
+
123
+ writeFileSync(join(base, "index.ts"), "export const x = 1;\n");
124
+ execFileSync("git", ["add", "."], { cwd: base });
125
+ execFileSync("git", ["commit", "-m", "initial"], { cwd: base });
126
+
127
+ // On initial commit, HEAD~1 does not exist — must not throw or produce wrong results
128
+ const audit = validateFileChanges(base, ["index.ts"], []);
129
+
130
+ assert.ok(audit !== null, "audit must be produced for initial commit");
131
+ assert.deepEqual(audit!.unexpectedFiles, [], "no unexpected files on initial commit");
132
+ assert.deepEqual(audit!.missingFiles, [], "no missing files on initial commit");
133
+ });
134
+
135
+ test("safety-harness-bug3: validateFileChanges works on shallow clone (shallow repo without full history)", (t) => {
136
+ // Simulate shallow clone: create a repo, then clone it with depth=1
137
+ const origin = mkdtempSync(join(tmpdir(), "gsd-origin-"));
138
+ const shallow = mkdtempSync(join(tmpdir(), "gsd-shallow-"));
139
+ t.after(() => {
140
+ rmSync(origin, { recursive: true, force: true });
141
+ rmSync(shallow, { recursive: true, force: true });
142
+ });
143
+
144
+ // Set up origin with multiple commits
145
+ execFileSync("git", ["init"], { cwd: origin });
146
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: origin });
147
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd: origin });
148
+ writeFileSync(join(origin, "a.ts"), "export const a = 1;\n");
149
+ execFileSync("git", ["add", "."], { cwd: origin });
150
+ execFileSync("git", ["commit", "-m", "first"], { cwd: origin });
151
+ writeFileSync(join(origin, "b.ts"), "export const b = 2;\n");
152
+ execFileSync("git", ["add", "."], { cwd: origin });
153
+ execFileSync("git", ["commit", "-m", "second"], { cwd: origin });
154
+
155
+ // Shallow clone with depth=1 — HEAD~1 will not exist
156
+ execFileSync("git", ["clone", "--depth=1", `file://${origin}`, shallow], {
157
+ stdio: ["ignore", "pipe", "pipe"],
158
+ });
159
+
160
+ // Verify the shallow clone has no parent (HEAD~1 unavailable)
161
+ let hasParent = true;
162
+ try {
163
+ execFileSync("git", ["rev-parse", "HEAD~1"], {
164
+ cwd: shallow,
165
+ stdio: ["ignore", "pipe", "pipe"],
166
+ });
167
+ } catch {
168
+ hasParent = false;
169
+ }
170
+ assert.equal(hasParent, false, "shallow clone should not have HEAD~1");
171
+
172
+ // validateFileChanges must not throw or give wrong results
173
+ const audit = validateFileChanges(shallow, ["b.ts"], []);
174
+ assert.ok(audit !== null, "audit must be produced even in shallow clone");
175
+ });
176
+
177
+ test("safety-harness-bug3: validateFileChanges works on merge commit", (t) => {
178
+ const base = mkdtempSync(join(tmpdir(), "gsd-merge-commit-"));
179
+ t.after(() => rmSync(base, { recursive: true, force: true }));
180
+
181
+ execFileSync("git", ["init", "-b", "main"], { cwd: base });
182
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: base });
183
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd: base });
184
+
185
+ // Main branch: initial commit
186
+ writeFileSync(join(base, "main.ts"), "export const m = 1;\n");
187
+ execFileSync("git", ["add", "."], { cwd: base });
188
+ execFileSync("git", ["commit", "-m", "initial"], { cwd: base });
189
+
190
+ // Feature branch
191
+ execFileSync("git", ["checkout", "-b", "feature"], { cwd: base });
192
+ writeFileSync(join(base, "feature.ts"), "export const f = 2;\n");
193
+ execFileSync("git", ["add", "."], { cwd: base });
194
+ execFileSync("git", ["commit", "-m", "feature work"], { cwd: base });
195
+
196
+ // Merge back to main
197
+ execFileSync("git", ["checkout", "main"], { cwd: base });
198
+ execFileSync("git", ["merge", "--no-ff", "feature", "-m", "Merge feature"], { cwd: base });
199
+
200
+ // HEAD is now a merge commit with two parents — git diff HEAD~1 gives wrong scope
201
+ const audit = validateFileChanges(base, ["feature.ts"], []);
202
+
203
+ // Must produce a valid result without throwing
204
+ assert.ok(audit !== null, "audit must be produced for merge commit repo");
205
+ });
@@ -109,6 +109,29 @@ test("plan-v2 gate fails closed for execution phase when finalized context is mi
109
109
  assert.match(compiled.reason ?? "", /CONTEXT\.md/i);
110
110
  });
111
111
 
112
+ test("plan-v2 gate accepts finalized context from project-root fallback", () => {
113
+ const projectRoot = createBasePath();
114
+ const worktreeBase = createBasePath();
115
+ seedGraphRows();
116
+
117
+ writeMilestoneFile(projectRoot, "CONTEXT", "Finalized context in project root.");
118
+ writeMilestoneFile(worktreeBase, "CONTEXT-DRAFT", "Draft context in worktree.");
119
+
120
+ const prevProjectRoot = process.env.GSD_PROJECT_ROOT;
121
+ process.env.GSD_PROJECT_ROOT = projectRoot;
122
+ try {
123
+ const compiled = ensurePlanV2Graph(worktreeBase, buildState("executing"));
124
+ assert.equal(compiled.ok, true);
125
+ assert.equal(compiled.finalizedContextIncluded, true);
126
+ } finally {
127
+ if (prevProjectRoot === undefined) {
128
+ delete process.env.GSD_PROJECT_ROOT;
129
+ } else {
130
+ process.env.GSD_PROJECT_ROOT = prevProjectRoot;
131
+ }
132
+ }
133
+ });
134
+
112
135
  test("plan-v2 compiler writes pipeline metadata for clarify/research/draft stages", () => {
113
136
  const basePath = createBasePath();
114
137
  seedGraphRows();
@@ -38,6 +38,29 @@ function hasFileContent(path: string | null): boolean {
38
38
  }
39
39
  }
40
40
 
41
+ function getArtifactLookupBases(basePath: string): string[] {
42
+ const bases = [basePath];
43
+ const projectRoot = process.env.GSD_PROJECT_ROOT;
44
+ if (projectRoot && projectRoot.trim().length > 0 && projectRoot !== basePath) {
45
+ bases.push(projectRoot);
46
+ }
47
+ return bases;
48
+ }
49
+
50
+ function hasMilestoneFileContent(
51
+ basePath: string,
52
+ milestoneId: string,
53
+ suffix: string,
54
+ ): boolean {
55
+ const bases = getArtifactLookupBases(basePath);
56
+ for (const candidateBase of bases) {
57
+ if (hasFileContent(resolveMilestoneFile(candidateBase, milestoneId, suffix))) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
41
64
  function countSliceResearchArtifacts(basePath: string, milestoneId: string, slices: SliceRow[]): number {
42
65
  let count = 0;
43
66
  for (const slice of slices) {
@@ -60,9 +83,9 @@ export function compileUnitGraphFromState(basePath: string, state: GSDState): Pl
60
83
  const slices = getMilestoneSlices(mid).sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0));
61
84
  const nodes: UokGraphNode[] = [];
62
85
  const clarifyRoundLimit = PLAN_V2_CLARIFY_ROUND_LIMIT;
63
- const draftContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"));
64
- const finalizedContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT"));
65
- const researchSynthesized = hasFileContent(resolveMilestoneFile(basePath, mid, "RESEARCH"))
86
+ const draftContextIncluded = hasMilestoneFileContent(basePath, mid, "CONTEXT-DRAFT");
87
+ const finalizedContextIncluded = hasMilestoneFileContent(basePath, mid, "CONTEXT");
88
+ const researchSynthesized = hasMilestoneFileContent(basePath, mid, "RESEARCH")
66
89
  || countSliceResearchArtifacts(basePath, mid, slices) > 0;
67
90
 
68
91
  if (isExecutionEntryPhase(state.phase) && !finalizedContextIncluded) {
@@ -58,7 +58,8 @@ export type LogComponent =
58
58
  | "memory-embeddings" // Memory layer embedding generation
59
59
  | "memory-ingest" // Memory layer ingestion pipeline
60
60
  | "memory-backfill" // ADR-013: decisions->memories backfill
61
- | "context-mode"; // Context-mode exec sandbox and compaction snapshot
61
+ | "context-mode" // Context-mode exec sandbox and compaction snapshot
62
+ | "preflight"; // Clean-root preflight gate at milestone completion
62
63
 
63
64
  export interface LogEntry {
64
65
  ts: string;