gsd-pi 2.57.0 → 2.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/resources/extensions/gsd/auto/infra-errors.js +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +3 -3
  3. package/dist/resources/extensions/gsd/auto-worktree.js +7 -2
  4. package/dist/resources/extensions/gsd/auto.js +4 -0
  5. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -1
  6. package/dist/resources/extensions/gsd/dispatch-guard.js +11 -1
  7. package/dist/resources/extensions/gsd/gsd-db.js +8 -1
  8. package/dist/resources/extensions/gsd/parallel-orchestrator.js +23 -6
  9. package/dist/resources/extensions/gsd/preferences.js +29 -15
  10. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  11. package/dist/resources/extensions/gsd/tools/validate-milestone.js +4 -0
  12. package/dist/web/standalone/.next/BUILD_ID +1 -1
  13. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  14. package/dist/web/standalone/.next/build-manifest.json +3 -3
  15. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  16. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  17. package/dist/web/standalone/.next/required-server-files.json +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  19. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  35. package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
  36. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  37. package/dist/web/standalone/.next/server/app/index.html +1 -1
  38. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  45. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  46. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/dist/web/standalone/.next/static/chunks/6502.8b732f67a11b11b4.js +9 -0
  51. package/dist/web/standalone/.next/static/chunks/{webpack-4332cbd5dd1be584.js → webpack-61d3afac6d0f0ce7.js} +1 -1
  52. package/dist/web/standalone/server.js +1 -1
  53. package/package.json +1 -1
  54. package/packages/daemon/src/cli.ts +49 -0
  55. package/packages/daemon/src/daemon.test.ts +104 -1
  56. package/packages/daemon/src/daemon.ts +23 -0
  57. package/packages/daemon/src/discord-bot.ts +62 -3
  58. package/packages/daemon/src/index.ts +9 -0
  59. package/packages/daemon/src/launchd.test.ts +356 -0
  60. package/packages/daemon/src/launchd.ts +242 -0
  61. package/packages/pi-coding-agent/package.json +1 -1
  62. package/pkg/package.json +1 -1
  63. package/src/resources/extensions/gsd/auto/infra-errors.ts +3 -0
  64. package/src/resources/extensions/gsd/auto-dispatch.ts +3 -3
  65. package/src/resources/extensions/gsd/auto-worktree.ts +7 -2
  66. package/src/resources/extensions/gsd/auto.ts +5 -0
  67. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -1
  68. package/src/resources/extensions/gsd/dispatch-guard.ts +12 -1
  69. package/src/resources/extensions/gsd/gsd-db.ts +6 -1
  70. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -6
  71. package/src/resources/extensions/gsd/preferences.ts +32 -14
  72. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  73. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +18 -0
  74. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +47 -0
  75. package/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +9 -8
  76. package/src/resources/extensions/gsd/tests/preferences.test.ts +34 -0
  77. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +7 -0
  78. package/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts +23 -1
  79. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +44 -2
  80. package/src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts +175 -0
  81. package/src/resources/extensions/gsd/tools/validate-milestone.ts +5 -0
  82. package/dist/web/standalone/.next/static/chunks/6502.2305d0afd2385711.js +0 -9
  83. /package/dist/web/standalone/.next/static/{yowc5qPtuKxjOr22KmOAy → IoheXIe-5DH7ieX8AUo8U}/_buildManifest.js +0 -0
  84. /package/dist/web/standalone/.next/static/{yowc5qPtuKxjOr22KmOAy → IoheXIe-5DH7ieX8AUo8U}/_ssgManifest.js +0 -0
@@ -924,7 +924,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
924
924
  promptSnippet: "Validate a GSD milestone (DB write + VALIDATION.md render)",
925
925
  promptGuidelines: [
926
926
  "Use gsd_validate_milestone when all slices are done and the milestone needs validation before completion.",
927
- "Parameters: milestoneId, verdict, remediationRound, successCriteriaChecklist, sliceDeliveryAudit, crossSliceIntegration, requirementCoverage, verdictRationale, remediationPlan (optional).",
927
+ "Parameters: milestoneId, verdict, remediationRound, successCriteriaChecklist, sliceDeliveryAudit, crossSliceIntegration, requirementCoverage, verificationClasses (optional), verdictRationale, remediationPlan (optional).",
928
928
  "If verdict is 'needs-remediation', also provide remediationPlan and use gsd_reassess_roadmap to add remediation slices to the roadmap.",
929
929
  "On success, returns validationPath where VALIDATION.md was written.",
930
930
  ],
@@ -936,6 +936,7 @@ export function registerDbTools(pi: ExtensionAPI): void {
936
936
  sliceDeliveryAudit: Type.String({ description: "Markdown table auditing each slice's claimed vs delivered output" }),
937
937
  crossSliceIntegration: Type.String({ description: "Markdown describing any cross-slice boundary mismatches" }),
938
938
  requirementCoverage: Type.String({ description: "Markdown describing any unaddressed requirements" }),
939
+ verificationClasses: Type.Optional(Type.String({ description: "Markdown describing verification class compliance and gaps" })),
939
940
  verdictRationale: Type.String({ description: "Why this verdict was chosen" }),
940
941
  remediationPlan: Type.Optional(Type.String({ description: "Remediation plan (required if verdict is needs-remediation)" })),
941
942
  }),
@@ -26,9 +26,20 @@ export function getPriorSliceCompletionBlocker(
26
26
  const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
27
27
  if (!targetMid || !targetSid) return null;
28
28
 
29
+ // Parallel worker isolation: when GSD_MILESTONE_LOCK is set, this worker
30
+ // is scoped to a single milestone. Skip the cross-milestone dependency
31
+ // check — other milestones are being handled by their own workers.
32
+ // Without this, the dispatch guard sees incomplete slices in M010/M011
33
+ // (cloned into the worktree DB) and blocks M012 from ever starting. #2797
34
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
35
+
29
36
  // Use findMilestoneIds to respect custom queue order.
30
37
  // Only check milestones that come BEFORE the target in queue order.
31
- const allIds = findMilestoneIds(base);
38
+ // When locked to a specific milestone, only check that milestone's
39
+ // intra-slice dependencies — skip all cross-milestone checks.
40
+ const allIds = milestoneLock && targetMid === milestoneLock
41
+ ? [targetMid]
42
+ : findMilestoneIds(base);
32
43
  const targetIdx = allIds.indexOf(targetMid);
33
44
  if (targetIdx < 0) return null;
34
45
  const milestoneIds = allIds.slice(0, targetIdx + 1);
@@ -6,7 +6,7 @@
6
6
  // Schema is initialized on first open with WAL mode for file-backed DBs.
7
7
 
8
8
  import { createRequire } from "node:module";
9
- import { existsSync, copyFileSync, mkdirSync } from "node:fs";
9
+ import { existsSync, copyFileSync, mkdirSync, realpathSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
11
  import type { Decision, Requirement, GateRow, GateId, GateScope, GateStatus, GateVerdict } from "./types.js";
12
12
  import { GSDError, GSD_STALE_STATE } from "./errors.js";
@@ -1761,6 +1761,11 @@ export function reconcileWorktreeDb(
1761
1761
  ): ReconcileResult {
1762
1762
  const zero: ReconcileResult = { decisions: 0, requirements: 0, artifacts: 0, milestones: 0, slices: 0, tasks: 0, memories: 0, verification_evidence: 0, conflicts: [] };
1763
1763
  if (!existsSync(worktreeDbPath)) return zero;
1764
+ // Guard: bail when both paths resolve to the same physical file.
1765
+ // ATTACHing a WAL-mode DB to itself corrupts the WAL (#2823).
1766
+ try {
1767
+ if (realpathSync(mainDbPath) === realpathSync(worktreeDbPath)) return zero;
1768
+ } catch { /* path resolution failed — fall through to existing checks */ }
1764
1769
  // Sanitize path: reject any characters that could break ATTACH syntax.
1765
1770
  // ATTACH DATABASE doesn't support parameterized paths in all providers,
1766
1771
  // so we use strict allowlist validation instead.
@@ -519,8 +519,19 @@ function createMilestoneWorktree(basePath: string, milestoneId: string): string
519
519
 
520
520
  /**
521
521
  * Spawn a worker process for a milestone.
522
- * The worker runs `gsd --print "/gsd auto"` in the milestone's worktree
522
+ * The worker runs `gsd headless --json auto` in the milestone's worktree
523
523
  * with GSD_MILESTONE_LOCK set to isolate state derivation.
524
+ *
525
+ * IMPORTANT: We use `headless --json auto` instead of `--print "/gsd auto"`.
526
+ * --print mode calls session.prompt() which returns immediately after the
527
+ * extension command handler fires, because auto-mode's ctx.newSession()
528
+ * resets the session and unblocks the outer prompt() await. This causes
529
+ * process.exit(0) to fire before any LLM work happens. See #2792.
530
+ *
531
+ * The headless subcommand uses an RPC client that keeps the process alive
532
+ * until auto-mode emits a terminal notification or the idle timer fires.
533
+ * It outputs NDJSON events to stdout (with --json), which our
534
+ * processWorkerLine() parser already understands.
524
535
  */
525
536
  export function spawnWorker(
526
537
  basePath: string,
@@ -537,7 +548,7 @@ export function spawnWorker(
537
548
 
538
549
  let child: ChildProcess;
539
550
  try {
540
- child = spawn(process.execPath, [binPath, "--mode", "json", "--print", "/gsd auto"], {
551
+ child = spawn(process.execPath, [binPath, "headless", "--json", "auto"], {
541
552
  cwd: worker.worktreePath,
542
553
  env: {
543
554
  ...process.env,
@@ -577,9 +588,10 @@ export function spawnWorker(
577
588
  }
578
589
 
579
590
  // ── NDJSON stdout monitoring ────────────────────────────────────────
580
- // Workers run with --mode json, emitting one JSON event per line.
581
- // We parse message_end events to extract cost/token usage, keeping
582
- // the coordinator's cost tracking in sync with actual API spend.
591
+ // Workers run via `headless --json`, which forwards all RPC events
592
+ // as NDJSON to stdout. We parse message_end events to extract
593
+ // cost/token usage, keeping the coordinator's cost tracking in sync
594
+ // with actual API spend.
583
595
  if (child.stdout) {
584
596
  let stdoutBuffer = "";
585
597
  child.stdout.on("data", (data: Buffer) => {
@@ -808,7 +820,12 @@ export async function stopParallel(
808
820
  } catch { /* process may already be dead */ }
809
821
  }
810
822
 
811
- const exitedAfterTerm = await waitForWorkerExit(worker, 750);
823
+ // Wait for the headless process to cascade SIGTERM to its RPC child.
824
+ // The headless signal handler calls client.stop() which sends SIGTERM
825
+ // to the RPC child and waits up to 1000ms. The previous 750ms window
826
+ // was insufficient — the parent got SIGKILL before the child died,
827
+ // leaving orphaned RPC processes holding auto.lock. See #2798.
828
+ const exitedAfterTerm = await waitForWorkerExit(worker, 3000);
812
829
  if (!exitedAfterTerm && worker.pid > 0) {
813
830
  try {
814
831
  if (worker.process) {
@@ -22,6 +22,7 @@ import { normalizeStringArray } from "../shared/format-utils.js";
22
22
  import { resolveProfileDefaults as _resolveProfileDefaults } from "./preferences-models.js";
23
23
 
24
24
  import {
25
+ KNOWN_PREFERENCE_KEYS,
25
26
  MODE_DEFAULTS,
26
27
  type WorkflowMode,
27
28
  type GSDPreferences,
@@ -250,7 +251,7 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
250
251
  * - planner: sonnet
251
252
  */
252
253
  function parseHeadingListFormat(content: string): GSDPreferences {
253
- const result: Record<string, Record<string, string>> = {};
254
+ const result: Record<string, string[]> = {};
254
255
  let currentSection: string | null = null;
255
256
 
256
257
  for (const rawLine of content.split('\n')) {
@@ -258,27 +259,44 @@ function parseHeadingListFormat(content: string): GSDPreferences {
258
259
  const headingMatch = line.match(/^##\s+(.+)$/);
259
260
  if (headingMatch) {
260
261
  currentSection = headingMatch[1].trim().toLowerCase().replace(/\s+/g, '_');
262
+ if (!result[currentSection]) result[currentSection] = [];
261
263
  continue;
262
264
  }
263
- if (currentSection) {
264
- const itemMatch = line.match(/^-\s+([^:]+):\s*(.*)$/);
265
- if (itemMatch) {
266
- if (!result[currentSection]) result[currentSection] = {};
267
- const value = itemMatch[2].trim();
268
- // Coerce "true"/"false" strings and numbers
269
- result[currentSection][itemMatch[1].trim()] = value;
270
- }
265
+ if (currentSection && line.trim() && !line.trimStart().startsWith('#')) {
266
+ result[currentSection].push(line);
271
267
  }
272
268
  }
273
269
 
274
- // Convert string values to appropriate types via YAML parser for each section
275
270
  const typed: Record<string, unknown> = {};
276
- for (const [section, entries] of Object.entries(result)) {
277
- const yamlLines = Object.entries(entries).map(([k, v]) => `${k}: ${v}`).join('\n');
271
+ for (const [section, lines] of Object.entries(result)) {
272
+ if (lines.length === 0) continue;
273
+
274
+ const usesLegacyListItems = lines.every((line) => /^\s*-\s+[^:]+:\s*.*$/.test(line));
275
+ const yamlBlock = usesLegacyListItems
276
+ ? lines.map((line) => line.replace(/^\s*-\s+/, '')).join('\n')
277
+ : lines.join('\n');
278
+
278
279
  try {
279
- typed[section] = parseYaml(yamlLines);
280
+ const parsed = parseYaml(yamlBlock);
281
+ if (typeof parsed !== 'object' || parsed === null) continue;
282
+
283
+ let targetSection = section;
284
+ let value: unknown = parsed;
285
+
286
+ if (!Array.isArray(parsed)) {
287
+ const keys = Object.keys(parsed);
288
+ if (keys.length === 1) {
289
+ const [onlyKey] = keys;
290
+ if (onlyKey === section || (!KNOWN_PREFERENCE_KEYS.has(section) && KNOWN_PREFERENCE_KEYS.has(onlyKey))) {
291
+ targetSection = onlyKey;
292
+ value = (parsed as Record<string, unknown>)[onlyKey];
293
+ }
294
+ }
295
+ }
296
+
297
+ typed[targetSection] = value;
280
298
  } catch {
281
- typed[section] = entries;
299
+ /* malformed section skip */
282
300
  }
283
301
  }
284
302
 
@@ -26,7 +26,7 @@ All relevant context has been preloaded below — the roadmap, all slice summari
26
26
  4. Check **requirement coverage** — are all active requirements addressed by at least one slice?
27
27
  5. If **Verification Classes** are provided in the inlined context above, check each non-empty class:
28
28
  - For each verification class (Contract, Integration, Operational, UAT), determine whether slice summaries, UAT results, or observable behavior provide evidence that this verification tier was addressed.
29
- - Document the compliance status of each class in your verdict rationale.
29
+ - Document the compliance status of each class in a dedicated verification classes section.
30
30
  - If `Operational` verification is non-empty and no evidence of operational verification exists, flag this explicitly — it means planned operational checks (migrations, deployments, runtime verification) were not proven.
31
31
  - A milestone with unaddressed verification classes may still pass if the gaps are minor, but the gaps MUST be documented in the Deferred Work Inventory.
32
32
  6. Determine a verdict:
@@ -36,7 +36,7 @@ All relevant context has been preloaded below — the roadmap, all slice summari
36
36
 
37
37
  ## Persist Validation
38
38
 
39
- **Persist validation results through `gsd_validate_milestone`.** Call it with: `milestoneId`, `verdict`, `remediationRound`, `successCriteriaChecklist`, `sliceDeliveryAudit`, `crossSliceIntegration`, `requirementCoverage`, `verdictRationale`, and `remediationPlan` (if verdict is `needs-remediation`). The tool writes the validation to the DB and renders VALIDATION.md to disk.
39
+ **Persist validation results through `gsd_validate_milestone`.** Call it with: `milestoneId`, `verdict`, `remediationRound`, `successCriteriaChecklist`, `sliceDeliveryAudit`, `crossSliceIntegration`, `requirementCoverage`, `verificationClasses` (when non-empty), `verdictRationale`, and `remediationPlan` (if verdict is `needs-remediation`). The tool writes the validation to the DB and renders VALIDATION.md to disk.
40
40
 
41
41
  If verdict is `needs-remediation`:
42
42
  - After calling `gsd_validate_milestone`, use `gsd_reassess_roadmap` to add remediation slices. Pass `milestoneId`, a synthetic `completedSliceId` (e.g. "VALIDATION"), `verdict: "roadmap-adjusted"`, `assessment` text, and `sliceChanges` with the new slices in the `added` array. The tool persists the changes to the DB and re-renders ROADMAP.md.
@@ -1233,6 +1233,24 @@ test("startAuto calls selfHealRuntimeRecords before autoLoop (#1727)", { skip: "
1233
1233
  );
1234
1234
  });
1235
1235
 
1236
+ test("startAuto guards against concurrent invocation (#2923)", () => {
1237
+ const src = readFileSync(
1238
+ resolve(import.meta.dirname, "..", "auto.ts"),
1239
+ "utf-8",
1240
+ );
1241
+ const fnIdx = src.indexOf("export async function startAuto");
1242
+ assert.ok(fnIdx > -1, "startAuto must exist in auto.ts");
1243
+ // The guard must appear before any other logic in the function body
1244
+ const fnBody = src.slice(fnIdx, fnIdx + 500);
1245
+ const activeGuard = fnBody.indexOf("if (s.active)");
1246
+ assert.ok(activeGuard > -1, "startAuto must check s.active to prevent concurrent auto-loops");
1247
+ const returnIdx = fnBody.indexOf("return;", activeGuard);
1248
+ assert.ok(
1249
+ returnIdx > -1 && returnIdx < activeGuard + 120,
1250
+ "s.active guard must early-return to prevent a second concurrent loop",
1251
+ );
1252
+ });
1253
+
1236
1254
  test("agent_end handler calls resolveAgentEnd (not handleAgentEnd)", () => {
1237
1255
  const hooksSrc = readFileSync(
1238
1256
  resolve(import.meta.dirname, "..", "bootstrap", "register-hooks.ts"),
@@ -216,3 +216,50 @@ test("dispatch guard works without git repo", (t) => {
216
216
 
217
217
  assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null);
218
218
  });
219
+
220
+ test("dispatch guard skips cross-milestone check when GSD_MILESTONE_LOCK is set (#2797)", (t) => {
221
+ const repo = setupRepo();
222
+ t.after(() => {
223
+ delete process.env.GSD_MILESTONE_LOCK;
224
+ teardownRepo(repo);
225
+ });
226
+
227
+ mkdirSync(join(repo, ".gsd", "milestones", "M010"), { recursive: true });
228
+ mkdirSync(join(repo, ".gsd", "milestones", "M011"), { recursive: true });
229
+ mkdirSync(join(repo, ".gsd", "milestones", "M012"), { recursive: true });
230
+
231
+ // M010 and M011 have incomplete slices
232
+ insertMilestone({ id: "M010", title: "Analytics" });
233
+ insertSlice({ id: "S01", milestoneId: "M010", title: "Data Quality", status: "pending", depends: [], sequence: 1 });
234
+
235
+ insertMilestone({ id: "M011", title: "Builder Onboarding" });
236
+ insertSlice({ id: "S01", milestoneId: "M011", title: "Schema", status: "pending", depends: [], sequence: 1 });
237
+
238
+ insertMilestone({ id: "M012", title: "Shared Components" });
239
+ insertSlice({ id: "S01", milestoneId: "M012", title: "Foundation", status: "pending", depends: [], sequence: 1 });
240
+ insertSlice({ id: "S02", milestoneId: "M012", title: "Migrate Pages", status: "pending", depends: ["S01"], sequence: 2 });
241
+
242
+ writeFileSync(join(repo, ".gsd", "milestones", "M010", "M010-ROADMAP.md"), "# M010\n");
243
+ writeFileSync(join(repo, ".gsd", "milestones", "M011", "M011-ROADMAP.md"), "# M011\n");
244
+ writeFileSync(join(repo, ".gsd", "milestones", "M012", "M012-ROADMAP.md"), "# M012\n");
245
+
246
+ // Without lock: M012 blocked by M010's incomplete S01
247
+ delete process.env.GSD_MILESTONE_LOCK;
248
+ assert.match(
249
+ getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M012/S01/T01") ?? "",
250
+ /earlier slice M010\/S01 is not complete/,
251
+ );
252
+
253
+ // With lock: M012 only checks its own intra-milestone deps — S01 has none, so unblocked
254
+ process.env.GSD_MILESTONE_LOCK = "M012";
255
+ assert.equal(
256
+ getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M012/S01/T01"),
257
+ null,
258
+ );
259
+
260
+ // With lock: M012/S02 still blocked by M012/S01 (intra-milestone dep preserved)
261
+ assert.equal(
262
+ getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M012/S02/T01"),
263
+ "Cannot dispatch execute-task M012/S02/T01: dependency slice M012/S01 is not complete.",
264
+ );
265
+ });
@@ -131,14 +131,15 @@ describe("parallel-worker-monitoring", () => {
131
131
  assert.ok(!(5.01 < ceiling), "5.01 is over ceiling");
132
132
  });
133
133
 
134
- it("worker spawn args include --mode json", () => {
135
- // Verify the spawn command includes JSON mode for NDJSON output.
136
- // We can't easily test the actual spawn, but we verify the args pattern.
137
- const expectedArgs = ["--mode", "json", "--print", "/gsd auto"];
138
- assert.ok(expectedArgs.includes("--mode"), "args include --mode");
139
- assert.ok(expectedArgs.includes("json"), "args include json");
140
- assert.ok(expectedArgs.indexOf("--mode") < expectedArgs.indexOf("json"),
141
- "--mode comes before json");
134
+ it("worker spawn args use headless --json auto (#2792)", () => {
135
+ // Verify the spawn command uses headless mode (not --print which exits
136
+ // before auto-mode can run). See #2792.
137
+ const expectedArgs = ["headless", "--json", "auto"];
138
+ assert.ok(expectedArgs.includes("headless"), "args include headless");
139
+ assert.ok(expectedArgs.includes("--json"), "args include --json");
140
+ assert.ok(expectedArgs.includes("auto"), "args include auto");
141
+ assert.ok(expectedArgs.indexOf("headless") < expectedArgs.indexOf("auto"),
142
+ "headless comes before auto");
142
143
  });
143
144
 
144
145
  it("refreshWorkerStatuses restores persisted workers from disk", () => {
@@ -352,6 +352,40 @@ test("handles empty models config", () => {
352
352
  assert.equal(prefs!.models, undefined);
353
353
  });
354
354
 
355
+ test("parses raw YAML blocks under headings", () => {
356
+ const content = `## Parallel
357
+ enabled: true
358
+ max_workers: 3
359
+ `;
360
+ const prefs = parsePreferencesMarkdown(content);
361
+ assert.notEqual(prefs, null);
362
+ assert.equal(prefs!.parallel?.enabled, true);
363
+ assert.equal(prefs!.parallel?.max_workers, 3);
364
+ });
365
+
366
+ test("unwraps nested top-level preference key under descriptive headings", () => {
367
+ const content = `## Parallel Orchestration
368
+ parallel:
369
+ enabled: true
370
+ max_workers: 3
371
+ `;
372
+ const prefs = parsePreferencesMarkdown(content);
373
+ assert.notEqual(prefs, null);
374
+ assert.equal(prefs!.parallel?.enabled, true);
375
+ assert.equal(prefs!.parallel?.max_workers, 3);
376
+ });
377
+
378
+ test("preserves legacy heading list format", () => {
379
+ const content = `## Git
380
+ - isolation: branch
381
+ - auto_push: true
382
+ `;
383
+ const prefs = parsePreferencesMarkdown(content);
384
+ assert.notEqual(prefs, null);
385
+ assert.equal(prefs!.git?.isolation, "branch");
386
+ assert.equal(prefs!.git?.auto_push, true);
387
+ });
388
+
355
389
  // ── Warn-once for unrecognized format (#2373) ────────────────────────────────
356
390
 
357
391
  test("unrecognized format warning is emitted at most once (#2373)", () => {
@@ -181,6 +181,13 @@ test("reassess-roadmap prompt references gsd_reassess_roadmap tool", () => {
181
181
  assert.match(prompt, /gsd_reassess_roadmap/);
182
182
  });
183
183
 
184
+ test("validate-milestone prompt persists verification classes through gsd_validate_milestone", () => {
185
+ const prompt = readPrompt("validate-milestone");
186
+ assert.match(prompt, /verification classes section/i);
187
+ assert.match(prompt, /verificationClasses/);
188
+ assert.match(prompt, /gsd_validate_milestone/);
189
+ });
190
+
184
191
  // ─── Prompt migration: replan-slice → gsd_replan_slice ────────────────
185
192
 
186
193
  test("replan-slice prompt names gsd_replan_slice as the tool to use", () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, afterEach } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
@@ -24,6 +24,7 @@ const VALID_PARAMS = {
24
24
  sliceDeliveryAudit: "| S01 | delivered |",
25
25
  crossSliceIntegration: "No issues",
26
26
  requirementCoverage: "All covered",
27
+ verificationClasses: "- Contract: covered\n- Integration: covered\n- Operational: gap noted",
27
28
  verdictRationale: "Everything checks out",
28
29
  };
29
30
 
@@ -59,6 +60,27 @@ describe("handleValidateMilestone write ordering (#2725)", () => {
59
60
  // Disk file exists
60
61
  const filePath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
61
62
  assert.ok(existsSync(filePath), "VALIDATION.md should exist on disk");
63
+ const validationMd = readFileSync(filePath, "utf-8");
64
+ assert.match(validationMd, /## Verification Class Compliance/);
65
+ assert.match(validationMd, /- Contract: covered/);
66
+ assert.match(validationMd, /## Verdict Rationale/);
67
+ });
68
+
69
+ it("omits verification class section when no verification classes are supplied", async () => {
70
+ base = makeTmpBase();
71
+ const dbPath = join(base, ".gsd", "gsd.db");
72
+ openDatabase(dbPath);
73
+ insertMilestone({ id: "M001" });
74
+
75
+ const result = await handleValidateMilestone(
76
+ { ...VALID_PARAMS, verificationClasses: undefined },
77
+ base,
78
+ );
79
+ assert.ok(!("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`);
80
+
81
+ const filePath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
82
+ const validationMd = readFileSync(filePath, "utf-8");
83
+ assert.doesNotMatch(validationMd, /## Verification Class Compliance/);
62
84
  });
63
85
 
64
86
  it("rolls back DB row when disk write fails", async () => {
@@ -23,9 +23,9 @@ import assert from "node:assert/strict";
23
23
  function hasOperationalEvidence(validationContent: string): boolean {
24
24
  const structuredMatch =
25
25
  validationContent.includes("Operational") &&
26
- (validationContent.includes("MET") || validationContent.includes("N/A"));
26
+ (validationContent.includes("MET") || validationContent.includes("N/A") || validationContent.includes("SATISFIED"));
27
27
  const proseMatch =
28
- /[Oo]perational[\s:][^\n]*(?:pass|verified|confirmed|met|complete|true|yes|addressed|covered|n\/a|not\s+applicable)/i.test(
28
+ /[Oo]perational[\s\S]{0,500}?(?:✅|pass|verified|confirmed|met|complete|true|yes|addressed|covered|satisfied|partially|n\/a|not[\s-]+applicable)/i.test(
29
29
  validationContent,
30
30
  );
31
31
  return structuredMatch || proseMatch;
@@ -104,6 +104,48 @@ test('prose: "Operational: complete" passes', () => {
104
104
  assert.ok(hasOperationalEvidence(content));
105
105
  });
106
106
 
107
+ // ─── Issue #2862: checkmark emoji ────────────────────────────────────────────
108
+
109
+ test('prose: "Operational: ✅" checkmark emoji passes (issue #2862)', () => {
110
+ const content = `- **Operational:** ✅ DECISIONS.md documents D009-D013`;
111
+ assert.ok(hasOperationalEvidence(content));
112
+ });
113
+
114
+ // ─── Issue #2866: multi-line, "satisfied", markdown bold ─────────────────────
115
+
116
+ test('multi-line: verdict on next line after Operational heading passes (issue #2866)', () => {
117
+ const content = `### Operational Verification
118
+ All endpoints responsive. Health checks pass.`;
119
+ assert.ok(hasOperationalEvidence(content));
120
+ });
121
+
122
+ test('prose: "PARTIALLY SATISFIED" passes (issue #2866)', () => {
123
+ const content = `Operational class: ⚠️ PARTIALLY SATISFIED`;
124
+ assert.ok(hasOperationalEvidence(content));
125
+ });
126
+
127
+ test('prose: "FULLY SATISFIED" passes (issue #2866)', () => {
128
+ const content = `**Operational**: FULLY SATISFIED — all monitoring in place.`;
129
+ assert.ok(hasOperationalEvidence(content));
130
+ });
131
+
132
+ test('structured: Operational + SATISFIED passes (issue #2866)', () => {
133
+ const content = `| Criteria | Status |
134
+ | Operational | SATISFIED |`;
135
+ assert.ok(hasOperationalEvidence(content));
136
+ });
137
+
138
+ test('table with markdown bold: **Operational** passes (issue #2866)', () => {
139
+ const content = `| **Operational** | ⚠️ Partially satisfied — monitoring gap noted |`;
140
+ assert.ok(hasOperationalEvidence(content));
141
+ });
142
+
143
+ test('multi-line: Operational label and "confirmed" separated by line break passes (issue #2866)', () => {
144
+ const content = `## Operational
145
+ Smoke tests confirmed all services healthy after deploy.`;
146
+ assert.ok(hasOperationalEvidence(content));
147
+ });
148
+
107
149
  // ─── Rejection cases ─────────────────────────────────────────────────────────
108
150
 
109
151
  test("no operational evidence: unrelated content fails", () => {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * worktree-db-same-file.test.ts — Regression test for #2823.
3
+ *
4
+ * Verifies that reconcileWorktreeDb() does not ATTACH a WAL-mode DB file
5
+ * to itself when the worktree DB path resolves to the same physical file
6
+ * as the main DB path (shared-WAL / symlink layout).
7
+ *
8
+ * Also verifies that the auto-loop classifies "database disk image is
9
+ * malformed" as an infrastructure error to prevent wasting retries.
10
+ */
11
+
12
+ import { describe, test, beforeEach, afterEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ rmSync,
19
+ symlinkSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+
25
+ import {
26
+ openDatabase,
27
+ closeDatabase,
28
+ reconcileWorktreeDb,
29
+ insertDecision,
30
+ } from "../gsd-db.ts";
31
+ import { isInfrastructureError } from "../auto/infra-errors.ts";
32
+
33
+ // ─── Fix 1 & 2: reconcileWorktreeDb same-file guard ─────────────────
34
+
35
+ describe("#2823: reconcileWorktreeDb same-file guard", () => {
36
+ let tmpDir: string;
37
+
38
+ beforeEach(() => {
39
+ tmpDir = mkdtempSync(join(tmpdir(), "gsd-2823-"));
40
+ });
41
+
42
+ afterEach(() => {
43
+ closeDatabase();
44
+ rmSync(tmpDir, { recursive: true, force: true });
45
+ });
46
+
47
+ test("returns zero result when both paths resolve to the same file", () => {
48
+ const mainGsd = join(tmpDir, "main", ".gsd");
49
+ mkdirSync(mainGsd, { recursive: true });
50
+ const mainDbPath = join(mainGsd, "gsd.db");
51
+
52
+ // Create a real DB at mainDbPath
53
+ openDatabase(mainDbPath);
54
+ insertDecision({
55
+ id: "D001",
56
+ when_context: "2026-01-01",
57
+ scope: "M001",
58
+ decision: "Test decision",
59
+ choice: "Test choice",
60
+ rationale: "Test rationale",
61
+ revisable: "yes",
62
+ made_by: "agent",
63
+ superseded_by: null,
64
+ });
65
+
66
+ // Create a worktree path that resolves to the same file via symlink
67
+ const wtGsd = join(tmpDir, "worktree", ".gsd");
68
+ mkdirSync(join(tmpDir, "worktree"), { recursive: true });
69
+ symlinkSync(mainGsd, wtGsd, "junction");
70
+ const worktreeDbPath = join(wtGsd, "gsd.db");
71
+
72
+ // Both paths exist and resolve to the same physical file
73
+ assert.ok(existsSync(mainDbPath), "main DB exists");
74
+ assert.ok(existsSync(worktreeDbPath), "worktree DB path exists (via symlink)");
75
+
76
+ // This should NOT attempt ATTACH — should return zero result
77
+ const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
78
+
79
+ assert.equal(result.decisions, 0, "no decisions reconciled");
80
+ assert.equal(result.requirements, 0, "no requirements reconciled");
81
+ assert.equal(result.artifacts, 0, "no artifacts reconciled");
82
+ assert.equal(result.conflicts.length, 0, "no conflicts");
83
+ });
84
+
85
+ test("returns zero result when both paths are identical strings", () => {
86
+ const mainGsd = join(tmpDir, "project", ".gsd");
87
+ mkdirSync(mainGsd, { recursive: true });
88
+ const dbPath = join(mainGsd, "gsd.db");
89
+
90
+ openDatabase(dbPath);
91
+ insertDecision({
92
+ id: "D001",
93
+ when_context: "2026-01-01",
94
+ scope: "M001",
95
+ decision: "Test",
96
+ choice: "Test",
97
+ rationale: "Test",
98
+ revisable: "yes",
99
+ made_by: "agent",
100
+ superseded_by: null,
101
+ });
102
+
103
+ // Same exact path — should bail immediately
104
+ const result = reconcileWorktreeDb(dbPath, dbPath);
105
+
106
+ assert.equal(result.decisions, 0);
107
+ assert.equal(result.conflicts.length, 0);
108
+ });
109
+
110
+ test("still reconciles when paths are genuinely different files", () => {
111
+ // Main DB
112
+ const mainGsd = join(tmpDir, "main", ".gsd");
113
+ mkdirSync(mainGsd, { recursive: true });
114
+ const mainDbPath = join(mainGsd, "gsd.db");
115
+
116
+ openDatabase(mainDbPath);
117
+ insertDecision({
118
+ id: "D001",
119
+ when_context: "2026-01-01",
120
+ scope: "M001",
121
+ decision: "Main decision",
122
+ choice: "Main choice",
123
+ rationale: "Main rationale",
124
+ revisable: "yes",
125
+ made_by: "agent",
126
+ superseded_by: null,
127
+ });
128
+ closeDatabase();
129
+
130
+ // Create a separate worktree DB with different data
131
+ const wtGsd = join(tmpDir, "worktree", ".gsd");
132
+ mkdirSync(wtGsd, { recursive: true });
133
+ const worktreeDbPath = join(wtGsd, "gsd.db");
134
+
135
+ openDatabase(worktreeDbPath);
136
+ insertDecision({
137
+ id: "D002",
138
+ when_context: "2026-01-01",
139
+ scope: "M001",
140
+ decision: "WT decision",
141
+ choice: "WT choice",
142
+ rationale: "WT rationale",
143
+ revisable: "yes",
144
+ made_by: "agent",
145
+ superseded_by: null,
146
+ });
147
+ closeDatabase();
148
+
149
+ // Re-open main and reconcile — should work normally
150
+ openDatabase(mainDbPath);
151
+ const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
152
+
153
+ assert.ok(
154
+ result.decisions > 0,
155
+ "should reconcile decisions from a genuinely different DB",
156
+ );
157
+ });
158
+ });
159
+
160
+ // ─── Fix 3: infrastructure error classification ─────────────────────
161
+
162
+ describe("#2823: malformed DB classified as infrastructure error", () => {
163
+ test("database disk image is malformed is detected as infra error", () => {
164
+ const err = new Error("database disk image is malformed");
165
+ const code = isInfrastructureError(err);
166
+ assert.ok(code !== null, "should be classified as infrastructure error");
167
+ assert.equal(code, "SQLITE_CORRUPT");
168
+ });
169
+
170
+ test("other SQLite errors are not falsely classified", () => {
171
+ const err = new Error("SQLITE_BUSY: database is locked");
172
+ const code = isInfrastructureError(err);
173
+ assert.equal(code, null, "SQLITE_BUSY should not be infra error (it's transient)");
174
+ });
175
+ });