gsd-pi 2.28.0-dev.4009980 → 2.28.0-dev.704ded6

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 (28) hide show
  1. package/dist/cli.js +15 -9
  2. package/dist/resource-loader.js +26 -2
  3. package/dist/resources/extensions/gsd/auto-recovery.ts +17 -1
  4. package/dist/resources/extensions/gsd/auto-start.ts +1 -1
  5. package/dist/resources/extensions/gsd/auto-verification.ts +27 -7
  6. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +14 -0
  7. package/dist/resources/extensions/gsd/auto.ts +20 -3
  8. package/dist/resources/extensions/gsd/export.ts +28 -2
  9. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +55 -0
  10. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  11. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  12. package/dist/resources/extensions/gsd/types.ts +1 -0
  13. package/dist/resources/extensions/gsd/verification-evidence.ts +2 -0
  14. package/dist/resources/extensions/gsd/verification-gate.ts +13 -2
  15. package/package.json +3 -3
  16. package/packages/pi-coding-agent/scripts/copy-assets.cjs +39 -8
  17. package/src/resources/extensions/gsd/auto-recovery.ts +17 -1
  18. package/src/resources/extensions/gsd/auto-start.ts +1 -1
  19. package/src/resources/extensions/gsd/auto-verification.ts +27 -7
  20. package/src/resources/extensions/gsd/auto-worktree-sync.ts +14 -0
  21. package/src/resources/extensions/gsd/auto.ts +20 -3
  22. package/src/resources/extensions/gsd/export.ts +28 -2
  23. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +55 -0
  24. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  25. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  26. package/src/resources/extensions/gsd/types.ts +1 -0
  27. package/src/resources/extensions/gsd/verification-evidence.ts +2 -0
  28. package/src/resources/extensions/gsd/verification-gate.ts +13 -2
@@ -581,7 +581,7 @@ test("formatFailureContext: formats a single failure with command, exit code, st
581
581
  const result: import("../types.ts").VerificationResult = {
582
582
  passed: false,
583
583
  checks: [
584
- { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500 },
584
+ { command: "npm run lint", exitCode: 1, stdout: "", stderr: "error: unused var", durationMs: 500, blocking: true },
585
585
  ],
586
586
  discoverySource: "preference",
587
587
  timestamp: Date.now(),
@@ -598,9 +598,9 @@ test("formatFailureContext: formats multiple failures", () => {
598
598
  const result: import("../types.ts").VerificationResult = {
599
599
  passed: false,
600
600
  checks: [
601
- { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100 },
602
- { command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200 },
603
- { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50 },
601
+ { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint error", durationMs: 100, blocking: true },
602
+ { command: "npm run test", exitCode: 2, stdout: "", stderr: "test failure", durationMs: 200, blocking: true },
603
+ { command: "npm run typecheck", exitCode: 0, stdout: "ok", stderr: "", durationMs: 50, blocking: true },
604
604
  ],
605
605
  discoverySource: "preference",
606
606
  timestamp: Date.now(),
@@ -619,7 +619,7 @@ test("formatFailureContext: truncates stderr longer than 2000 chars", () => {
619
619
  const result: import("../types.ts").VerificationResult = {
620
620
  passed: false,
621
621
  checks: [
622
- { command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100 },
622
+ { command: "big-err", exitCode: 1, stdout: "", stderr: longStderr, durationMs: 100, blocking: true },
623
623
  ],
624
624
  discoverySource: "preference",
625
625
  timestamp: Date.now(),
@@ -634,8 +634,8 @@ test("formatFailureContext: returns empty string when all checks pass", () => {
634
634
  const result: import("../types.ts").VerificationResult = {
635
635
  passed: true,
636
636
  checks: [
637
- { command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100 },
638
- { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200 },
637
+ { command: "npm run lint", exitCode: 0, stdout: "ok", stderr: "", durationMs: 100, blocking: true },
638
+ { command: "npm run test", exitCode: 0, stdout: "ok", stderr: "", durationMs: 200, blocking: true },
639
639
  ],
640
640
  discoverySource: "preference",
641
641
  timestamp: Date.now(),
@@ -663,6 +663,7 @@ test("formatFailureContext: caps total output at 10,000 chars", () => {
663
663
  stdout: "",
664
664
  stderr: "e".repeat(1000), // 1000 chars each, 20 * ~1050 (with formatting) > 10,000
665
665
  durationMs: 100,
666
+ blocking: true,
666
667
  });
667
668
  }
668
669
  const result: import("../types.ts").VerificationResult = {
@@ -1077,3 +1078,131 @@ test("dependency-audit: subdirectory package.json does not trigger audit", () =>
1077
1078
  assert.equal(npmAuditCalled, false, "subdirectory dependency files should not trigger audit");
1078
1079
  assert.deepStrictEqual(result, []);
1079
1080
  });
1081
+
1082
+ // ─── Non-Blocking Discovery Tests ────────────────────────────────────────────
1083
+
1084
+ test("non-blocking: package-json discovered commands failing → result.passed is still true", () => {
1085
+ const tmp = makeTempDir("vg-nb-pkg-fail");
1086
+ try {
1087
+ writeFileSync(
1088
+ join(tmp, "package.json"),
1089
+ JSON.stringify({ scripts: { lint: "eslint .", test: "vitest" } }),
1090
+ );
1091
+ // These commands will fail because eslint/vitest don't exist in the temp dir
1092
+ const result = runVerificationGate({
1093
+ basePath: tmp,
1094
+ unitId: "T01",
1095
+ cwd: tmp,
1096
+ // No preference commands — discovery falls through to package.json
1097
+ });
1098
+ assert.equal(result.discoverySource, "package-json");
1099
+ assert.ok(result.checks.length > 0, "should have discovered package.json checks");
1100
+ assert.equal(result.passed, true, "package-json failures should not block the gate");
1101
+ for (const check of result.checks) {
1102
+ assert.equal(check.blocking, false, "package-json checks should be non-blocking");
1103
+ }
1104
+ } finally {
1105
+ rmSync(tmp, { recursive: true, force: true });
1106
+ }
1107
+ });
1108
+
1109
+ test("non-blocking: preference commands failing → result.passed is false", () => {
1110
+ const tmp = makeTempDir("vg-nb-pref-fail");
1111
+ try {
1112
+ const result = runVerificationGate({
1113
+ basePath: tmp,
1114
+ unitId: "T01",
1115
+ cwd: tmp,
1116
+ preferenceCommands: ["sh -c 'exit 1'"],
1117
+ });
1118
+ assert.equal(result.discoverySource, "preference");
1119
+ assert.equal(result.passed, false, "preference failures should block the gate");
1120
+ assert.equal(result.checks[0].blocking, true, "preference checks should be blocking");
1121
+ } finally {
1122
+ rmSync(tmp, { recursive: true, force: true });
1123
+ }
1124
+ });
1125
+
1126
+ test("non-blocking: task-plan commands failing → result.passed is false", () => {
1127
+ const tmp = makeTempDir("vg-nb-tp-fail");
1128
+ try {
1129
+ const result = runVerificationGate({
1130
+ basePath: tmp,
1131
+ unitId: "T01",
1132
+ cwd: tmp,
1133
+ taskPlanVerify: "sh -c 'exit 1'",
1134
+ });
1135
+ assert.equal(result.discoverySource, "task-plan");
1136
+ assert.equal(result.passed, false, "task-plan failures should block the gate");
1137
+ assert.equal(result.checks[0].blocking, true, "task-plan checks should be blocking");
1138
+ } finally {
1139
+ rmSync(tmp, { recursive: true, force: true });
1140
+ }
1141
+ });
1142
+
1143
+ test("non-blocking: blocking field is set correctly based on discovery source", () => {
1144
+ const tmp = makeTempDir("vg-nb-field");
1145
+ try {
1146
+ // preference → blocking
1147
+ const prefResult = runVerificationGate({
1148
+ basePath: tmp,
1149
+ unitId: "T01",
1150
+ cwd: tmp,
1151
+ preferenceCommands: ["echo ok"],
1152
+ });
1153
+ assert.equal(prefResult.checks[0].blocking, true);
1154
+
1155
+ // task-plan → blocking
1156
+ const tpResult = runVerificationGate({
1157
+ basePath: tmp,
1158
+ unitId: "T01",
1159
+ cwd: tmp,
1160
+ taskPlanVerify: "echo ok",
1161
+ });
1162
+ assert.equal(tpResult.checks[0].blocking, true);
1163
+
1164
+ // package-json → non-blocking
1165
+ writeFileSync(
1166
+ join(tmp, "package.json"),
1167
+ JSON.stringify({ scripts: { test: "echo ok" } }),
1168
+ );
1169
+ const pkgResult = runVerificationGate({
1170
+ basePath: tmp,
1171
+ unitId: "T01",
1172
+ cwd: tmp,
1173
+ });
1174
+ assert.equal(pkgResult.checks[0].blocking, false);
1175
+ } finally {
1176
+ rmSync(tmp, { recursive: true, force: true });
1177
+ }
1178
+ });
1179
+
1180
+ test("non-blocking: formatFailureContext only includes blocking failures", () => {
1181
+ const result: import("../types.ts").VerificationResult = {
1182
+ passed: true,
1183
+ checks: [
1184
+ { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false },
1185
+ { command: "npm run test", exitCode: 1, stdout: "", stderr: "test error", durationMs: 200, blocking: true },
1186
+ { command: "npm run typecheck", exitCode: 1, stdout: "", stderr: "type error", durationMs: 50, blocking: false },
1187
+ ],
1188
+ discoverySource: "preference",
1189
+ timestamp: Date.now(),
1190
+ };
1191
+ const output = formatFailureContext(result);
1192
+ assert.ok(output.includes("`npm run test`"), "should include blocking failure");
1193
+ assert.ok(!output.includes("npm run lint"), "should not include non-blocking failure");
1194
+ assert.ok(!output.includes("npm run typecheck"), "should not include non-blocking failure");
1195
+ });
1196
+
1197
+ test("non-blocking: formatFailureContext returns empty when only non-blocking failures exist", () => {
1198
+ const result: import("../types.ts").VerificationResult = {
1199
+ passed: true,
1200
+ checks: [
1201
+ { command: "npm run lint", exitCode: 1, stdout: "", stderr: "lint warning", durationMs: 100, blocking: false },
1202
+ { command: "npm run test", exitCode: 1, stdout: "", stderr: "test warning", durationMs: 200, blocking: false },
1203
+ ],
1204
+ discoverySource: "package-json",
1205
+ timestamp: Date.now(),
1206
+ };
1207
+ assert.equal(formatFailureContext(result), "", "should return empty when only non-blocking failures");
1208
+ });
@@ -55,6 +55,7 @@ export interface VerificationCheck {
55
55
  stdout: string;
56
56
  stderr: string;
57
57
  durationMs: number;
58
+ blocking: boolean; // true for preference/task-plan sources, false for package-json (advisory only)
58
59
  }
59
60
 
60
61
  /** A runtime error captured from bg-shell processes or browser console */
@@ -20,6 +20,7 @@ export interface EvidenceCheckJSON {
20
20
  exitCode: number;
21
21
  durationMs: number;
22
22
  verdict: "pass" | "fail";
23
+ blocking: boolean;
23
24
  }
24
25
 
25
26
  export interface RuntimeErrorJSON {
@@ -80,6 +81,7 @@ export function writeVerificationJSON(
80
81
  exitCode: check.exitCode,
81
82
  durationMs: check.durationMs,
82
83
  verdict: check.exitCode === 0 ? "pass" : "fail",
84
+ blocking: check.blocking,
83
85
  })),
84
86
  ...(retryAttempt !== undefined ? { retryAttempt } : {}),
85
87
  ...(maxRetries !== undefined ? { maxRetries } : {}),
@@ -112,7 +112,9 @@ const MAX_FAILURE_CONTEXT_CHARS = 10_000;
112
112
  * Returns an empty string when all checks pass or the checks array is empty.
113
113
  */
114
114
  export function formatFailureContext(result: VerificationResult): string {
115
- const failures = result.checks.filter((c) => c.exitCode !== 0);
115
+ // Only include blocking failures in retry context non-blocking (advisory) failures
116
+ // should not be injected into retry prompts to avoid noise pollution.
117
+ const failures = result.checks.filter((c) => c.exitCode !== 0 && c.blocking);
116
118
  if (failures.length === 0) return "";
117
119
 
118
120
  const blocks: string[] = [];
@@ -256,6 +258,10 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
256
258
  };
257
259
  }
258
260
 
261
+ // Commands from preference and task-plan sources are blocking;
262
+ // package-json discovered commands are advisory (non-blocking).
263
+ const blocking = source === "preference" || source === "task-plan";
264
+
259
265
  const checks: VerificationCheck[] = [];
260
266
 
261
267
  for (const command of commands) {
@@ -291,11 +297,16 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
291
297
  stdout: truncate(result.stdout, MAX_OUTPUT_BYTES),
292
298
  stderr,
293
299
  durationMs,
300
+ blocking,
294
301
  });
295
302
  }
296
303
 
304
+ // Gate passes if all blocking checks pass (non-blocking failures are advisory)
305
+ const blockingChecks = checks.filter(c => c.blocking);
306
+ const passed = blockingChecks.length === 0 || blockingChecks.every(c => c.exitCode === 0);
307
+
297
308
  return {
298
- passed: checks.every(c => c.exitCode === 0),
309
+ passed,
299
310
  checks,
300
311
  discoverySource: source,
301
312
  timestamp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.28.0-dev.4009980",
3
+ "version": "2.28.0-dev.704ded6",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "configDir": ".gsd"
36
36
  },
37
37
  "engines": {
38
- "node": ">=20.6.0"
38
+ "node": ">=22.0.0"
39
39
  },
40
40
  "packageManager": "npm@10.9.3",
41
41
  "scripts": {
@@ -117,7 +117,7 @@
117
117
  "zod-to-json-schema": "^3.24.6"
118
118
  },
119
119
  "devDependencies": {
120
- "@types/node": "^22.0.0",
120
+ "@types/node": "^24.12.0",
121
121
  "@types/picomatch": "^4.0.2",
122
122
  "c8": "^11.0.0",
123
123
  "jiti": "^2.6.1",
@@ -1,24 +1,55 @@
1
1
  #!/usr/bin/env node
2
- const { mkdirSync, cpSync } = require('fs');
2
+ const { mkdirSync, cpSync, copyFileSync, readdirSync } = require('fs');
3
+ const { join } = require('path');
4
+
5
+ /**
6
+ * Recursive directory copy using copyFileSync — workaround for cpSync failures
7
+ * on Windows paths containing non-ASCII characters (#1178).
8
+ */
9
+ function safeCpSync(src, dest, options) {
10
+ try {
11
+ cpSync(src, dest, options);
12
+ } catch {
13
+ if (options && options.recursive) {
14
+ copyDirRecursive(src, dest, options && options.filter);
15
+ } else {
16
+ copyFileSync(src, dest);
17
+ }
18
+ }
19
+ }
20
+
21
+ function copyDirRecursive(src, dest, filter) {
22
+ mkdirSync(dest, { recursive: true });
23
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
24
+ const srcPath = join(src, entry.name);
25
+ const destPath = join(dest, entry.name);
26
+ if (filter && !filter(srcPath)) continue;
27
+ if (entry.isDirectory()) {
28
+ copyDirRecursive(srcPath, destPath, filter);
29
+ } else {
30
+ copyFileSync(srcPath, destPath);
31
+ }
32
+ }
33
+ }
3
34
 
4
35
  // Theme assets
5
36
  mkdirSync('dist/modes/interactive/theme', { recursive: true });
6
- cpSync('src/modes/interactive/theme', 'dist/modes/interactive/theme', {
37
+ safeCpSync('src/modes/interactive/theme', 'dist/modes/interactive/theme', {
7
38
  recursive: true,
8
39
  filter: (s) => !s.endsWith('.ts'),
9
40
  });
10
41
 
11
42
  // Export HTML templates and vendor files
12
43
  mkdirSync('dist/core/export-html/vendor', { recursive: true });
13
- cpSync('src/core/export-html/template.html', 'dist/core/export-html/template.html');
14
- cpSync('src/core/export-html/template.css', 'dist/core/export-html/template.css');
15
- cpSync('src/core/export-html/template.js', 'dist/core/export-html/template.js');
16
- cpSync('src/core/export-html/vendor', 'dist/core/export-html/vendor', {
44
+ safeCpSync('src/core/export-html/template.html', 'dist/core/export-html/template.html');
45
+ safeCpSync('src/core/export-html/template.css', 'dist/core/export-html/template.css');
46
+ safeCpSync('src/core/export-html/template.js', 'dist/core/export-html/template.js');
47
+ safeCpSync('src/core/export-html/vendor', 'dist/core/export-html/vendor', {
17
48
  recursive: true,
18
49
  filter: (s) => !s.endsWith('.ts'),
19
50
  });
20
51
 
21
52
  // LSP defaults
22
53
  mkdirSync('dist/core/lsp', { recursive: true });
23
- cpSync('src/core/lsp/defaults.json', 'dist/core/lsp/defaults.json');
24
- cpSync('src/core/lsp/lsp.md', 'dist/core/lsp/lsp.md');
54
+ safeCpSync('src/core/lsp/defaults.json', 'dist/core/lsp/defaults.json');
55
+ safeCpSync('src/core/lsp/lsp.md', 'dist/core/lsp/lsp.md');
@@ -36,6 +36,7 @@ import {
36
36
  clearPathCache,
37
37
  resolveGsdRootFile,
38
38
  } from "./paths.js";
39
+ import { isValidationTerminal } from "./state.js";
39
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
40
41
  import { atomicWriteSync } from "./atomic-write.js";
41
42
  import { dirname, join } from "node:path";
@@ -137,6 +138,21 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
137
138
  if (!absPath) return false;
138
139
  if (!existsSync(absPath)) return false;
139
140
 
141
+ // validate-milestone must have a VALIDATION file with a terminal verdict
142
+ // (pass, needs-attention, or needs-remediation). Without this check, a
143
+ // VALIDATION file with missing/malformed frontmatter or an unrecognized
144
+ // verdict is treated as "complete" by the artifact check but deriveState
145
+ // still returns phase:"validating-milestone" (because isValidationTerminal
146
+ // returns false), creating an infinite skip loop that hits the lifetime cap.
147
+ if (unitType === "validate-milestone") {
148
+ try {
149
+ const validationContent = readFileSync(absPath, "utf-8");
150
+ if (!isValidationTerminal(validationContent)) return false;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
140
156
  // plan-slice must produce a plan with actual task entries, not just a scaffold.
141
157
  // The plan file may exist from a prior discussion/context step with only headings
142
158
  // but no tasks. Without this check the artifact is considered "complete" and the
@@ -211,7 +227,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
211
227
  try {
212
228
  const roadmapContent = readFileSync(roadmapFile, "utf-8");
213
229
  const roadmap = parseRoadmap(roadmapContent);
214
- const slice = roadmap.slices.find(s => s.id === sid);
230
+ const slice = (roadmap.slices ?? []).find(s => s.id === sid);
215
231
  if (slice && !slice.done) return false;
216
232
  } catch {
217
233
  // Corrupt/unparseable roadmap — fail verification so the unit
@@ -415,7 +415,7 @@ export async function bootstrapAutoSession(
415
415
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
416
416
  ctx.ui.setFooter(hideFooter);
417
417
  const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
418
- const pendingCount = state.registry.filter(m => m.status !== 'complete' && m.status !== 'parked').length;
418
+ const pendingCount = (state.registry ?? []).filter(m => m.status !== 'complete' && m.status !== 'parked').length;
419
419
  const scopeMsg = pendingCount > 1
420
420
  ? `Will loop through ${pendingCount} milestones.`
421
421
  : "Will loop until milestone complete.";
@@ -105,19 +105,39 @@ export async function runPostUnitVerification(
105
105
  const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
106
106
 
107
107
  if (result.checks.length > 0) {
108
- const passCount = result.checks.filter(c => c.exitCode === 0).length;
109
- const total = result.checks.length;
108
+ const blockingChecks = result.checks.filter(c => c.blocking);
109
+ const advisoryChecks = result.checks.filter(c => !c.blocking);
110
+ const blockingPassCount = blockingChecks.filter(c => c.exitCode === 0).length;
111
+ const advisoryFailCount = advisoryChecks.filter(c => c.exitCode !== 0).length;
112
+
110
113
  if (result.passed) {
111
- ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`);
114
+ let msg = blockingChecks.length > 0
115
+ ? `Verification gate: ${blockingPassCount}/${blockingChecks.length} blocking checks passed`
116
+ : `Verification gate: passed (no blocking checks)`;
117
+ if (advisoryFailCount > 0) {
118
+ msg += ` (${advisoryFailCount} advisory warning${advisoryFailCount > 1 ? "s" : ""})`;
119
+ }
120
+ ctx.ui.notify(msg);
121
+ // Log advisory warnings to stderr for visibility
122
+ if (advisoryFailCount > 0) {
123
+ const advisoryFailures = advisoryChecks.filter(c => c.exitCode !== 0);
124
+ process.stderr.write(`verification-gate: ${advisoryFailCount} advisory (non-blocking) failure(s)\n`);
125
+ for (const f of advisoryFailures) {
126
+ process.stderr.write(` [advisory] ${f.command} exited ${f.exitCode}\n`);
127
+ }
128
+ }
112
129
  } else {
113
- const failures = result.checks.filter(c => c.exitCode !== 0);
114
- const failNames = failures.map(f => f.command).join(", ");
130
+ const blockingFailures = blockingChecks.filter(c => c.exitCode !== 0);
131
+ const failNames = blockingFailures.map(f => f.command).join(", ");
115
132
  ctx.ui.notify(`Verification gate: FAILED — ${failNames}`);
116
- process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`);
117
- for (const f of failures) {
133
+ process.stderr.write(`verification-gate: ${blockingFailures.length}/${blockingChecks.length} blocking checks failed\n`);
134
+ for (const f of blockingFailures) {
118
135
  process.stderr.write(` ${f.command} exited ${f.exitCode}\n`);
119
136
  if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
120
137
  }
138
+ if (advisoryFailCount > 0) {
139
+ process.stderr.write(`verification-gate: ${advisoryFailCount} additional advisory (non-blocking) failure(s)\n`);
140
+ }
121
141
  }
122
142
  }
123
143
 
@@ -36,6 +36,12 @@ export function syncProjectRootToWorktree(projectRoot: string, worktreePath: str
36
36
  // has newer artifacts (e.g. slices that don't exist in the worktree yet)
37
37
  safeCopyRecursive(join(prGsd, "milestones", milestoneId), join(wtGsd, "milestones", milestoneId))
38
38
 
39
+ // Copy living documents from project root to worktree so agents have the
40
+ // latest decisions, requirements, project state, and knowledge.
41
+ for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
42
+ safeCopy(join(prGsd, doc), join(wtGsd, doc), { force: true });
43
+ }
44
+
39
45
  // Delete worktree gsd.db so it rebuilds from the freshly synced files.
40
46
  // Stale DB rows are the root cause of the infinite skip loop (#853).
41
47
  try {
@@ -89,6 +95,14 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string
89
95
  // worktree. If the next session resolves basePath before worktree re-entry,
90
96
  // selfHeal can't find or clear the stale record (#769).
91
97
  safeCopyRecursive(join(wtGsd, "runtime", "units"), join(prGsd, "runtime", "units"), { force: true })
98
+
99
+ // 5. Living documents — decisions, requirements, project description, knowledge.
100
+ // Agents update these during slice execution. Without syncing, a new session
101
+ // reads stale copies from the project root, losing architectural decisions,
102
+ // requirement status updates, and accumulated knowledge (#1168).
103
+ for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
104
+ safeCopy(join(wtGsd, doc), join(prGsd, doc), { force: true });
105
+ }
92
106
  }
93
107
 
94
108
  // ─── Resource Staleness ───────────────────────────────────────────────────
@@ -877,7 +877,7 @@ async function showStepWizard(
877
877
  : "previous unit";
878
878
 
879
879
  if (!mid || state.phase === "complete") {
880
- const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
880
+ const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
881
881
  if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked" && state.phase !== "pre-planning") {
882
882
  const ids = incomplete.map(m => m.id).join(", ");
883
883
  const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@@ -1171,7 +1171,7 @@ async function dispatchNextUnit(
1171
1171
  }
1172
1172
  }
1173
1173
 
1174
- const pendingIds = state.registry
1174
+ const pendingIds = (state.registry ?? [])
1175
1175
  .filter(m => m.status !== "complete")
1176
1176
  .map(m => m.id);
1177
1177
  pruneQueueOrder(s.basePath, pendingIds);
@@ -1186,7 +1186,7 @@ async function dispatchNextUnit(
1186
1186
  await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
1187
1187
  }
1188
1188
 
1189
- const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
1189
+ const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
1190
1190
  if (incomplete.length === 0) {
1191
1191
  // Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
1192
1192
  if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
@@ -1439,6 +1439,23 @@ async function dispatchNextUnit(
1439
1439
 
1440
1440
  await runSecretsGate();
1441
1441
 
1442
+ // ── Interactive discussion gate ──
1443
+ // If the active milestone needs discussion (has CONTEXT-DRAFT.md but no roadmap),
1444
+ // stop auto-mode and route to the interactive discussion flow. The guided-flow
1445
+ // handles needs-discussion correctly — it just needs to be called instead of
1446
+ // letting the dispatch table fire "needs-discussion → stop" (#1170).
1447
+ if (state.phase === "needs-discussion") {
1448
+ if (s.currentUnit) {
1449
+ await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
1450
+ }
1451
+ const cmdCtx = s.cmdCtx!;
1452
+ const basePath = s.basePath;
1453
+ await stopAuto(ctx, pi, `${mid}: ${midTitle} needs discussion before planning.`);
1454
+ const { showSmartEntry } = await import("./guided-flow.js");
1455
+ await showSmartEntry(cmdCtx, pi, basePath);
1456
+ return;
1457
+ }
1458
+
1442
1459
  // ── Dispatch table ──
1443
1460
  const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs,
1444
1461
  });
@@ -4,6 +4,7 @@
4
4
  import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
5
5
  import { writeFileSync, mkdirSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
+ import { exec } from "node:child_process";
7
8
  import {
8
9
  getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice,
9
10
  aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk,
@@ -12,6 +13,28 @@ import type { UnitMetrics } from "./metrics.js";
12
13
  import { gsdRoot } from "./paths.js";
13
14
  import { formatDuration, fileLink } from "../shared/mod.js";
14
15
 
16
+ /**
17
+ * Open a file in the user's default browser.
18
+ * Uses platform-specific commands: `open` (macOS), `xdg-open` (Linux), `start` (Windows).
19
+ * Non-blocking, non-fatal — failures are silently ignored.
20
+ */
21
+ export function openInBrowser(filePath: string): void {
22
+ const cmd =
23
+ process.platform === "darwin" ? "open" :
24
+ process.platform === "win32" ? "start" :
25
+ "xdg-open";
26
+
27
+ // On Windows, `start` needs an empty title argument when the path has spaces
28
+ const args = process.platform === "win32"
29
+ ? `"" "${filePath}"`
30
+ : `"${filePath}"`;
31
+
32
+ exec(`${cmd} ${args}`, (err) => {
33
+ // Non-fatal — if the browser can't be opened, the file path is still shown
34
+ if (err) void err;
35
+ });
36
+ }
37
+
15
38
  /**
16
39
  * Write an export file directly, without requiring an ExtensionCommandContext.
17
40
  * Used by the visualizer overlay export tab.
@@ -167,10 +190,12 @@ export async function handleExport(args: string, ctx: ExtensionCommandContext, b
167
190
  paths.push(bn(outPath));
168
191
  }
169
192
 
193
+ const indexPath = join(gsdRoot(basePath), "reports", "index.html");
170
194
  ctx.ui.notify(
171
- `Generated ${paths.length} report snapshot${paths.length !== 1 ? "s" : ""}:\n${paths.map(p => ` ${p}`).join("\n")}\nBrowse all reports: .gsd/reports/index.html`,
195
+ `Generated ${paths.length} report snapshot${paths.length !== 1 ? "s" : ""}:\n${paths.map(p => ` ${p}`).join("\n")}\nOpening reports index in browser...`,
172
196
  "success",
173
197
  );
198
+ openInBrowser(indexPath);
174
199
  } else {
175
200
  // Single report for the active milestone (existing behavior)
176
201
  const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
@@ -194,9 +219,10 @@ export async function handleExport(args: string, ctx: ExtensionCommandContext, b
194
219
  phase: data.phase,
195
220
  });
196
221
  ctx.ui.notify(
197
- `HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`,
222
+ `HTML report saved: .gsd/reports/${bn(outPath)}\nOpening in browser...`,
198
223
  "success",
199
224
  );
225
+ openInBrowser(outPath);
200
226
  }
201
227
  } catch (err) {
202
228
  ctx.ui.notify(
@@ -290,6 +290,61 @@ test("verifyExpectedArtifact fails when VALIDATION.md is missing", () => {
290
290
  }
291
291
  });
292
292
 
293
+ test("verifyExpectedArtifact rejects VALIDATION with missing frontmatter", () => {
294
+ const base = makeTmpBase();
295
+ try {
296
+ // A VALIDATION file without frontmatter should be treated as incomplete —
297
+ // matching what deriveState expects. Without this, the artifact check passes
298
+ // but deriveState still returns validating-milestone, causing the hard skip loop.
299
+ writeValidation(base, "M001", "# Validation\nNo frontmatter here.");
300
+ clearPathCache();
301
+ clearParseCache();
302
+ const result = verifyExpectedArtifact("validate-milestone", "M001", base);
303
+ assert.equal(result, false, "VALIDATION without frontmatter should fail verification");
304
+ } finally {
305
+ cleanup(base);
306
+ }
307
+ });
308
+
309
+ test("verifyExpectedArtifact rejects VALIDATION with missing verdict field", () => {
310
+ const base = makeTmpBase();
311
+ try {
312
+ writeValidation(base, "M001", "---\nremediation_round: 0\n---\n\n# Validation");
313
+ clearPathCache();
314
+ clearParseCache();
315
+ const result = verifyExpectedArtifact("validate-milestone", "M001", base);
316
+ assert.equal(result, false, "VALIDATION without verdict field should fail verification");
317
+ } finally {
318
+ cleanup(base);
319
+ }
320
+ });
321
+
322
+ test("verifyExpectedArtifact rejects VALIDATION with unrecognized verdict", () => {
323
+ const base = makeTmpBase();
324
+ try {
325
+ writeValidation(base, "M001", "---\nverdict: unknown-value\nremediation_round: 0\n---\n\n# Validation");
326
+ clearPathCache();
327
+ clearParseCache();
328
+ const result = verifyExpectedArtifact("validate-milestone", "M001", base);
329
+ assert.equal(result, false, "VALIDATION with unrecognized verdict should fail verification");
330
+ } finally {
331
+ cleanup(base);
332
+ }
333
+ });
334
+
335
+ test("verifyExpectedArtifact passes VALIDATION with needs-attention verdict", () => {
336
+ const base = makeTmpBase();
337
+ try {
338
+ writeValidation(base, "M001", "---\nverdict: needs-attention\nremediation_round: 0\n---\n\n# Validation\nNeeds attention.");
339
+ clearPathCache();
340
+ clearParseCache();
341
+ const result = verifyExpectedArtifact("validate-milestone", "M001", base);
342
+ assert.equal(result, true, "VALIDATION with needs-attention verdict should pass verification");
343
+ } finally {
344
+ cleanup(base);
345
+ }
346
+ });
347
+
293
348
  // ─── diagnoseExpectedArtifact ─────────────────────────────────────────────
294
349
 
295
350
  test("diagnoseExpectedArtifact returns validation path for validate-milestone", () => {