gsd-pi 2.59.0-dev.d77b3dd → 2.60.0-dev.2580e65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/extensions/ask-user-questions.js +7 -4
- package/dist/resources/extensions/gsd/auto/phases.js +15 -7
- package/dist/resources/extensions/gsd/auto-dashboard.js +21 -8
- package/dist/resources/extensions/gsd/auto-dispatch.js +6 -3
- package/dist/resources/extensions/gsd/auto-model-selection.js +58 -9
- package/dist/resources/extensions/gsd/auto-post-unit.js +3 -2
- package/dist/resources/extensions/gsd/auto-prompts.js +36 -20
- package/dist/resources/extensions/gsd/auto-recovery.js +37 -18
- package/dist/resources/extensions/gsd/auto-start.js +9 -5
- package/dist/resources/extensions/gsd/auto-timers.js +11 -5
- package/dist/resources/extensions/gsd/auto-unit-closeout.js +5 -3
- package/dist/resources/extensions/gsd/auto-verification.js +3 -2
- package/dist/resources/extensions/gsd/auto-worktree.js +120 -55
- package/dist/resources/extensions/gsd/auto.js +39 -17
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +6 -3
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -2
- package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +4 -10
- package/dist/resources/extensions/gsd/bootstrap/journal-tools.js +2 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -0
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -10
- package/dist/resources/extensions/gsd/commands/catalog.js +2 -0
- package/dist/resources/extensions/gsd/commands-codebase.js +48 -21
- package/dist/resources/extensions/gsd/commands-inspect.js +2 -1
- package/dist/resources/extensions/gsd/commands-maintenance.js +32 -19
- package/dist/resources/extensions/gsd/complexity-classifier.js +8 -4
- package/dist/resources/extensions/gsd/custom-verification.js +3 -2
- package/dist/resources/extensions/gsd/gsd-db.js +33 -13
- package/dist/resources/extensions/gsd/guided-flow.js +19 -9
- package/dist/resources/extensions/gsd/init-wizard.js +12 -0
- package/dist/resources/extensions/gsd/markdown-renderer.js +11 -9
- package/dist/resources/extensions/gsd/md-importer.js +5 -4
- package/dist/resources/extensions/gsd/milestone-actions.js +3 -2
- package/dist/resources/extensions/gsd/milestone-ids.js +2 -1
- package/dist/resources/extensions/gsd/model-router.js +156 -121
- package/dist/resources/extensions/gsd/parallel-merge.js +5 -3
- package/dist/resources/extensions/gsd/parallel-orchestrator.js +26 -14
- package/dist/resources/extensions/gsd/preferences-types.js +1 -0
- package/dist/resources/extensions/gsd/preferences-validation.js +45 -0
- package/dist/resources/extensions/gsd/preferences.js +15 -3
- package/dist/resources/extensions/gsd/prompt-loader.js +3 -2
- package/dist/resources/extensions/gsd/prompts/rethink.md +1 -1
- package/dist/resources/extensions/gsd/rule-registry.js +7 -6
- package/dist/resources/extensions/gsd/safe-fs.js +6 -8
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +3 -2
- package/dist/resources/extensions/gsd/tools/complete-slice.js +3 -2
- package/dist/resources/extensions/gsd/tools/complete-task.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-milestone.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-slice.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-task.js +2 -1
- package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +4 -4
- package/dist/resources/extensions/gsd/tools/reopen-slice.js +2 -1
- package/dist/resources/extensions/gsd/tools/reopen-task.js +2 -1
- package/dist/resources/extensions/gsd/tools/replan-slice.js +2 -1
- package/dist/resources/extensions/gsd/tools/validate-milestone.js +2 -1
- package/dist/resources/extensions/gsd/triage-resolution.js +11 -4
- package/dist/resources/extensions/gsd/workflow-events.js +2 -1
- package/dist/resources/extensions/gsd/workflow-logger.js +37 -4
- package/dist/resources/extensions/gsd/workflow-migration.js +14 -12
- package/dist/resources/extensions/gsd/workflow-projections.js +2 -2
- package/dist/resources/extensions/gsd/workflow-reconcile.js +2 -2
- package/dist/resources/extensions/gsd/worktree-manager.js +26 -14
- package/dist/resources/extensions/shared/interview-ui.js +3 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +19 -19
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +16 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +26 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js +6 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/defaults.json +2 -2
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js +47 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js.map +1 -0
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +19 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +26 -0
- package/packages/pi-coding-agent/src/core/lsp/config.ts +7 -1
- package/packages/pi-coding-agent/src/core/lsp/defaults.json +2 -2
- package/packages/pi-coding-agent/src/core/lsp/lsp-legacy-alias.test.ts +70 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/ask-user-questions.ts +7 -3
- package/src/resources/extensions/gsd/auto/phases.ts +17 -7
- package/src/resources/extensions/gsd/auto-dashboard.ts +22 -8
- package/src/resources/extensions/gsd/auto-dispatch.ts +7 -3
- package/src/resources/extensions/gsd/auto-model-selection.ts +77 -15
- package/src/resources/extensions/gsd/auto-post-unit.ts +4 -4
- package/src/resources/extensions/gsd/auto-prompts.ts +37 -20
- package/src/resources/extensions/gsd/auto-recovery.ts +38 -18
- package/src/resources/extensions/gsd/auto-start.ts +10 -9
- package/src/resources/extensions/gsd/auto-timers.ts +12 -5
- package/src/resources/extensions/gsd/auto-unit-closeout.ts +6 -2
- package/src/resources/extensions/gsd/auto-verification.ts +3 -6
- package/src/resources/extensions/gsd/auto-worktree.ts +121 -55
- package/src/resources/extensions/gsd/auto.ts +40 -17
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -3
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -2
- package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +4 -16
- package/src/resources/extensions/gsd/bootstrap/journal-tools.ts +2 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +8 -0
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -10
- package/src/resources/extensions/gsd/commands/catalog.ts +2 -0
- package/src/resources/extensions/gsd/commands-codebase.ts +52 -20
- package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
- package/src/resources/extensions/gsd/commands-maintenance.ts +28 -19
- package/src/resources/extensions/gsd/complexity-classifier.ts +9 -4
- package/src/resources/extensions/gsd/custom-verification.ts +3 -2
- package/src/resources/extensions/gsd/gsd-db.ts +12 -14
- package/src/resources/extensions/gsd/guided-flow.ts +9 -8
- package/src/resources/extensions/gsd/init-wizard.ts +12 -0
- package/src/resources/extensions/gsd/markdown-renderer.ts +11 -17
- package/src/resources/extensions/gsd/md-importer.ts +5 -4
- package/src/resources/extensions/gsd/milestone-actions.ts +3 -2
- package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
- package/src/resources/extensions/gsd/model-router.ts +199 -173
- package/src/resources/extensions/gsd/parallel-merge.ts +5 -3
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +18 -14
- package/src/resources/extensions/gsd/preferences-types.ts +13 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +45 -0
- package/src/resources/extensions/gsd/preferences.ts +16 -3
- package/src/resources/extensions/gsd/prompt-loader.ts +3 -2
- package/src/resources/extensions/gsd/prompts/rethink.md +1 -1
- package/src/resources/extensions/gsd/rule-registry.ts +7 -6
- package/src/resources/extensions/gsd/safe-fs.ts +6 -5
- package/src/resources/extensions/gsd/tests/capability-router.test.ts +347 -0
- package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +63 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +27 -2
- package/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +1188 -0
- package/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts +841 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +403 -3
- package/src/resources/extensions/gsd/tests/preferences.test.ts +62 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +21 -0
- package/src/resources/extensions/gsd/tests/silent-catch-diagnostics.test.ts +284 -0
- package/src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +6 -6
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +3 -6
- package/src/resources/extensions/gsd/tools/complete-slice.ts +3 -6
- package/src/resources/extensions/gsd/tools/complete-task.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-milestone.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-slice.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-task.ts +2 -3
- package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +4 -6
- package/src/resources/extensions/gsd/tools/reopen-slice.ts +2 -3
- package/src/resources/extensions/gsd/tools/reopen-task.ts +2 -3
- package/src/resources/extensions/gsd/tools/replan-slice.ts +2 -3
- package/src/resources/extensions/gsd/tools/validate-milestone.ts +2 -3
- package/src/resources/extensions/gsd/triage-resolution.ts +11 -4
- package/src/resources/extensions/gsd/types.ts +1 -0
- package/src/resources/extensions/gsd/workflow-events.ts +2 -1
- package/src/resources/extensions/gsd/workflow-logger.ts +52 -5
- package/src/resources/extensions/gsd/workflow-migration.ts +14 -12
- package/src/resources/extensions/gsd/workflow-projections.ts +2 -2
- package/src/resources/extensions/gsd/workflow-reconcile.ts +2 -2
- package/src/resources/extensions/gsd/worktree-manager.ts +16 -14
- package/src/resources/extensions/shared/interview-ui.ts +3 -1
- package/src/resources/extensions/shared/tests/interview-notes-loop.test.ts +144 -0
- /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-machine-edge-cases.test.ts — Gap-filling tests for the GSD state
|
|
3
|
+
* machine covering failure modes, boundary conditions, and edge cases NOT
|
|
4
|
+
* covered by the existing state-machine-live-validation.test.ts suite.
|
|
5
|
+
*
|
|
6
|
+
* Coverage gaps filled:
|
|
7
|
+
* 1. State derivation failures (file deletion races, partial DB, cache staleness,
|
|
8
|
+
* corrupt files, 0-slice ROADMAP)
|
|
9
|
+
* 2. Transition boundary failures (mid-transition mutation, cascading blockers,
|
|
10
|
+
* multi-level milestone deps, blocked→unblocked recovery)
|
|
11
|
+
* 3. Dispatch failures (null activeSlice, evaluating-gates without config,
|
|
12
|
+
* unhandled phase, missing task plan recovery)
|
|
13
|
+
* 4. Completion & verification failures (unparseable verdict, needs-remediation
|
|
14
|
+
* blocks completion, missing SUMMARY blocks validation, UAT verdict gate,
|
|
15
|
+
* replan loop cap)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// GSD State Machine Edge Case Tests
|
|
19
|
+
|
|
20
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import {
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
// ── DB layer ──────────────────────────────────────────────────────────────
|
|
35
|
+
import {
|
|
36
|
+
openDatabase,
|
|
37
|
+
closeDatabase,
|
|
38
|
+
insertMilestone,
|
|
39
|
+
insertSlice,
|
|
40
|
+
insertTask,
|
|
41
|
+
getTask,
|
|
42
|
+
getSlice,
|
|
43
|
+
getMilestone,
|
|
44
|
+
getSliceTasks,
|
|
45
|
+
getMilestoneSlices,
|
|
46
|
+
updateTaskStatus,
|
|
47
|
+
updateSliceStatus,
|
|
48
|
+
updateMilestoneStatus,
|
|
49
|
+
insertReplanHistory,
|
|
50
|
+
getReplanHistory,
|
|
51
|
+
insertGateRow,
|
|
52
|
+
getPendingGates,
|
|
53
|
+
} from "../../gsd-db.ts";
|
|
54
|
+
|
|
55
|
+
// ── State derivation ──────────────────────────────────────────────────────
|
|
56
|
+
import {
|
|
57
|
+
deriveState,
|
|
58
|
+
deriveStateFromDb,
|
|
59
|
+
invalidateStateCache,
|
|
60
|
+
isGhostMilestone,
|
|
61
|
+
isValidationTerminal,
|
|
62
|
+
} from "../../state.ts";
|
|
63
|
+
|
|
64
|
+
// ── Status guards ─────────────────────────────────────────────────────────
|
|
65
|
+
import { isClosedStatus } from "../../status-guards.ts";
|
|
66
|
+
|
|
67
|
+
// ── Cache invalidation ───────────────────────────────────────────────────
|
|
68
|
+
import { invalidateAllCaches } from "../../cache.ts";
|
|
69
|
+
|
|
70
|
+
// ── Dispatch ─────────────────────────────────────────────────────────────
|
|
71
|
+
import {
|
|
72
|
+
resolveDispatch,
|
|
73
|
+
DISPATCH_RULES,
|
|
74
|
+
getDispatchRuleNames,
|
|
75
|
+
} from "../../auto-dispatch.ts";
|
|
76
|
+
import type { DispatchContext, DispatchAction } from "../../auto-dispatch.ts";
|
|
77
|
+
|
|
78
|
+
// ── Verdict parser ──────────────────────────────────────────────────────
|
|
79
|
+
import {
|
|
80
|
+
extractVerdict,
|
|
81
|
+
isAcceptableUatVerdict,
|
|
82
|
+
isValidMilestoneVerdict,
|
|
83
|
+
} from "../../verdict-parser.ts";
|
|
84
|
+
|
|
85
|
+
// ── Path helpers ─────────────────────────────────────────────────────────
|
|
86
|
+
import { clearPathCache } from "../../paths.ts";
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
// Fixture Helpers
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
function makeTempDir(): string {
|
|
93
|
+
return mkdtempSync(join(tmpdir(), "gsd-edge-cases-"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a standard .gsd/ fixture with M001 containing S01 (2 tasks) and S02 (1 task).
|
|
98
|
+
* Same structure as state-machine-live-validation.test.ts for consistency.
|
|
99
|
+
*/
|
|
100
|
+
function createFullFixture(): string {
|
|
101
|
+
const base = makeTempDir();
|
|
102
|
+
const gsdDir = join(base, ".gsd");
|
|
103
|
+
const m001Dir = join(gsdDir, "milestones", "M001");
|
|
104
|
+
const s01Dir = join(m001Dir, "slices", "S01");
|
|
105
|
+
const s01Tasks = join(s01Dir, "tasks");
|
|
106
|
+
const s02Dir = join(m001Dir, "slices", "S02");
|
|
107
|
+
const s02Tasks = join(s02Dir, "tasks");
|
|
108
|
+
|
|
109
|
+
mkdirSync(s01Tasks, { recursive: true });
|
|
110
|
+
mkdirSync(s02Tasks, { recursive: true });
|
|
111
|
+
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(m001Dir, "M001-CONTEXT.md"),
|
|
114
|
+
[
|
|
115
|
+
"# M001: Edge Case Milestone",
|
|
116
|
+
"",
|
|
117
|
+
"## Purpose",
|
|
118
|
+
"Test state machine edge cases.",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(m001Dir, "M001-ROADMAP.md"),
|
|
124
|
+
[
|
|
125
|
+
"# M001: Edge Case Milestone",
|
|
126
|
+
"",
|
|
127
|
+
"## Vision",
|
|
128
|
+
"Prove edge case correctness.",
|
|
129
|
+
"",
|
|
130
|
+
"## Success Criteria",
|
|
131
|
+
"- All edge cases handled",
|
|
132
|
+
"",
|
|
133
|
+
"## Slices",
|
|
134
|
+
"",
|
|
135
|
+
"- [ ] **S01: First Feature** `risk:low` `depends:[]`",
|
|
136
|
+
" - After this: First feature proven.",
|
|
137
|
+
"",
|
|
138
|
+
"- [ ] **S02: Second Feature** `risk:low` `depends:[]`",
|
|
139
|
+
" - After this: Second feature proven.",
|
|
140
|
+
"",
|
|
141
|
+
"## Boundary Map",
|
|
142
|
+
"",
|
|
143
|
+
"| From | To | Produces | Consumes |",
|
|
144
|
+
"|------|----|----------|----------|",
|
|
145
|
+
"| S01 | terminal | feature-a | nothing |",
|
|
146
|
+
"| S02 | terminal | feature-b | nothing |",
|
|
147
|
+
].join("\n"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
writeFileSync(
|
|
151
|
+
join(s01Dir, "S01-PLAN.md"),
|
|
152
|
+
[
|
|
153
|
+
"# S01: First Feature",
|
|
154
|
+
"",
|
|
155
|
+
"**Goal:** Implement first feature.",
|
|
156
|
+
"",
|
|
157
|
+
"## Tasks",
|
|
158
|
+
"",
|
|
159
|
+
"- [ ] **T01: Implementation** `est:30m`",
|
|
160
|
+
" - Do: Build it",
|
|
161
|
+
" - Verify: Run tests",
|
|
162
|
+
"",
|
|
163
|
+
"- [ ] **T02: Testing** `est:30m`",
|
|
164
|
+
" - Do: Write tests",
|
|
165
|
+
" - Verify: Run tests",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
writeFileSync(join(s01Tasks, "T01-PLAN.md"), "# T01 Plan\nImplement.\n");
|
|
170
|
+
writeFileSync(join(s01Tasks, "T02-PLAN.md"), "# T02 Plan\nTest.\n");
|
|
171
|
+
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(s02Dir, "S02-PLAN.md"),
|
|
174
|
+
[
|
|
175
|
+
"# S02: Second Feature",
|
|
176
|
+
"",
|
|
177
|
+
"**Goal:** Implement second feature.",
|
|
178
|
+
"",
|
|
179
|
+
"## Tasks",
|
|
180
|
+
"",
|
|
181
|
+
"- [ ] **T01: Implementation** `est:30m`",
|
|
182
|
+
" - Do: Build it",
|
|
183
|
+
" - Verify: Run tests",
|
|
184
|
+
].join("\n"),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
writeFileSync(join(s02Tasks, "T01-PLAN.md"), "# T01 Plan\nBuild.\n");
|
|
188
|
+
|
|
189
|
+
return base;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a multi-milestone fixture with M001 → M002 → M003 dependency chain.
|
|
194
|
+
*/
|
|
195
|
+
function createMultiMilestoneFixture(): string {
|
|
196
|
+
const base = makeTempDir();
|
|
197
|
+
const gsdDir = join(base, ".gsd");
|
|
198
|
+
|
|
199
|
+
for (const mid of ["M001", "M002", "M003"]) {
|
|
200
|
+
const mDir = join(gsdDir, "milestones", mid);
|
|
201
|
+
const sDir = join(mDir, "slices", "S01", "tasks");
|
|
202
|
+
mkdirSync(sDir, { recursive: true });
|
|
203
|
+
|
|
204
|
+
writeFileSync(
|
|
205
|
+
join(mDir, `${mid}-CONTEXT.md`),
|
|
206
|
+
`# ${mid}: Milestone ${mid.slice(-1)}\n\n## Purpose\nTest deps.\n`,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
writeFileSync(
|
|
210
|
+
join(mDir, `${mid}-ROADMAP.md`),
|
|
211
|
+
[
|
|
212
|
+
`# ${mid}: Milestone ${mid.slice(-1)}`,
|
|
213
|
+
"",
|
|
214
|
+
"## Vision",
|
|
215
|
+
"Test dependency chains.",
|
|
216
|
+
"",
|
|
217
|
+
"## Success Criteria",
|
|
218
|
+
"- Works",
|
|
219
|
+
"",
|
|
220
|
+
"## Slices",
|
|
221
|
+
"",
|
|
222
|
+
"- [ ] **S01: Only Slice** `risk:low` `depends:[]`",
|
|
223
|
+
" - After this: Done.",
|
|
224
|
+
"",
|
|
225
|
+
"## Boundary Map",
|
|
226
|
+
"",
|
|
227
|
+
"| From | To | Produces | Consumes |",
|
|
228
|
+
"|------|----|----------|----------|",
|
|
229
|
+
"| S01 | terminal | output | nothing |",
|
|
230
|
+
].join("\n"),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
writeFileSync(
|
|
234
|
+
join(mDir, "slices", "S01", "S01-PLAN.md"),
|
|
235
|
+
[
|
|
236
|
+
"# S01: Only Slice",
|
|
237
|
+
"",
|
|
238
|
+
"**Goal:** Do the thing.",
|
|
239
|
+
"",
|
|
240
|
+
"## Tasks",
|
|
241
|
+
"",
|
|
242
|
+
"- [ ] **T01: Task** `est:30m`",
|
|
243
|
+
" - Do: Implement",
|
|
244
|
+
" - Verify: Run tests",
|
|
245
|
+
].join("\n"),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return base;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildDispatchCtx(
|
|
255
|
+
base: string,
|
|
256
|
+
mid: string,
|
|
257
|
+
stateOverrides: Partial<import("../../types.ts").GSDState> = {},
|
|
258
|
+
): DispatchContext {
|
|
259
|
+
return {
|
|
260
|
+
basePath: base,
|
|
261
|
+
mid,
|
|
262
|
+
midTitle: `${mid} Test`,
|
|
263
|
+
state: {
|
|
264
|
+
activeMilestone: { id: mid, title: `${mid} Test` },
|
|
265
|
+
activeSlice: null,
|
|
266
|
+
activeTask: null,
|
|
267
|
+
phase: "executing",
|
|
268
|
+
recentDecisions: [],
|
|
269
|
+
blockers: [],
|
|
270
|
+
nextAction: "",
|
|
271
|
+
registry: [],
|
|
272
|
+
requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
|
|
273
|
+
progress: { milestones: { done: 0, total: 1 } },
|
|
274
|
+
...stateOverrides,
|
|
275
|
+
},
|
|
276
|
+
prefs: undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
281
|
+
// Test Suite
|
|
282
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
283
|
+
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
285
|
+
// SECTION 1: State Derivation Failure Modes
|
|
286
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe("state derivation failures", () => {
|
|
289
|
+
let base: string;
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
293
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("file deleted between deriveState calls produces consistent result", async () => {
|
|
297
|
+
// Simulates race condition: PLAN file exists on first derive, deleted before second
|
|
298
|
+
base = createFullFixture();
|
|
299
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
300
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
301
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
|
|
302
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
303
|
+
|
|
304
|
+
invalidateAllCaches();
|
|
305
|
+
const stateBefore = await deriveStateFromDb(base);
|
|
306
|
+
assert.equal(stateBefore.phase, "executing");
|
|
307
|
+
|
|
308
|
+
// Delete the task plan file mid-flow
|
|
309
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md");
|
|
310
|
+
if (existsSync(planPath)) unlinkSync(planPath);
|
|
311
|
+
|
|
312
|
+
invalidateAllCaches();
|
|
313
|
+
const stateAfter = await deriveStateFromDb(base);
|
|
314
|
+
// State machine should still function — either executing (DB says task exists)
|
|
315
|
+
// or planning (missing plan file triggers replan). Should NOT throw.
|
|
316
|
+
assert.ok(
|
|
317
|
+
["executing", "planning"].includes(stateAfter.phase),
|
|
318
|
+
`expected executing or planning after plan deletion, got: ${stateAfter.phase}`,
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("partial DB write: milestone inserted but no slices → pre-planning", async () => {
|
|
323
|
+
base = makeTempDir();
|
|
324
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
325
|
+
mkdirSync(mDir, { recursive: true });
|
|
326
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\n## Purpose\nTest.\n");
|
|
327
|
+
|
|
328
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
329
|
+
// Only insert milestone — no slices, no roadmap
|
|
330
|
+
insertMilestone({ id: "M001", title: "Partial", status: "active" });
|
|
331
|
+
|
|
332
|
+
invalidateAllCaches();
|
|
333
|
+
const state = await deriveStateFromDb(base);
|
|
334
|
+
// No roadmap → pre-planning (milestone exists but no structure yet)
|
|
335
|
+
assert.equal(state.phase, "pre-planning");
|
|
336
|
+
assert.equal(state.activeMilestone?.id, "M001");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("cache staleness: derive within TTL returns same result after DB mutation", async () => {
|
|
340
|
+
base = createFullFixture();
|
|
341
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
342
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
343
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
|
|
344
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
345
|
+
|
|
346
|
+
// First call populates cache
|
|
347
|
+
invalidateStateCache();
|
|
348
|
+
const state1 = await deriveState(base);
|
|
349
|
+
assert.equal(state1.phase, "executing");
|
|
350
|
+
|
|
351
|
+
// Mutate DB WITHOUT invalidating cache
|
|
352
|
+
updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());
|
|
353
|
+
|
|
354
|
+
// Second call within 100ms TTL should return cached (stale) result
|
|
355
|
+
const state2 = await deriveState(base);
|
|
356
|
+
assert.equal(state2.phase, "executing", "cached result should still show executing");
|
|
357
|
+
|
|
358
|
+
// After explicit invalidation, should reflect the DB mutation
|
|
359
|
+
invalidateStateCache();
|
|
360
|
+
const state3 = await deriveState(base);
|
|
361
|
+
assert.equal(state3.phase, "summarizing", "after cache invalidation should show summarizing");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("corrupt ROADMAP: binary content does not crash deriveState", async () => {
|
|
365
|
+
base = makeTempDir();
|
|
366
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
367
|
+
mkdirSync(mDir, { recursive: true });
|
|
368
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Corrupt\n\n## Purpose\nTest.\n");
|
|
369
|
+
// Write binary garbage as ROADMAP
|
|
370
|
+
writeFileSync(join(mDir, "M001-ROADMAP.md"), Buffer.from([0x00, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]));
|
|
371
|
+
|
|
372
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
373
|
+
insertMilestone({ id: "M001", title: "Corrupt", status: "active" });
|
|
374
|
+
|
|
375
|
+
invalidateAllCaches();
|
|
376
|
+
// Should NOT throw — should degrade gracefully
|
|
377
|
+
const state = await deriveStateFromDb(base);
|
|
378
|
+
assert.ok(state.phase, "should produce a valid phase even with corrupt ROADMAP");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("0-byte ROADMAP file is treated as no roadmap (pre-planning)", async () => {
|
|
382
|
+
base = makeTempDir();
|
|
383
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
384
|
+
mkdirSync(mDir, { recursive: true });
|
|
385
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Empty\n\n## Purpose\nTest.\n");
|
|
386
|
+
writeFileSync(join(mDir, "M001-ROADMAP.md"), "");
|
|
387
|
+
|
|
388
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
389
|
+
insertMilestone({ id: "M001", title: "Empty", status: "active" });
|
|
390
|
+
|
|
391
|
+
invalidateAllCaches();
|
|
392
|
+
const state = await deriveStateFromDb(base);
|
|
393
|
+
assert.equal(state.phase, "pre-planning", "empty ROADMAP should result in pre-planning");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("ROADMAP with no ## Slices section derives pre-planning", async () => {
|
|
397
|
+
base = makeTempDir();
|
|
398
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
399
|
+
mkdirSync(mDir, { recursive: true });
|
|
400
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: No Slices\n\n## Purpose\nTest.\n");
|
|
401
|
+
writeFileSync(
|
|
402
|
+
join(mDir, "M001-ROADMAP.md"),
|
|
403
|
+
[
|
|
404
|
+
"# M001: No Slices",
|
|
405
|
+
"",
|
|
406
|
+
"## Vision",
|
|
407
|
+
"Test zero slices.",
|
|
408
|
+
"",
|
|
409
|
+
"## Success Criteria",
|
|
410
|
+
"- Works",
|
|
411
|
+
"",
|
|
412
|
+
"## Slices",
|
|
413
|
+
"",
|
|
414
|
+
"## Boundary Map",
|
|
415
|
+
"",
|
|
416
|
+
"| From | To | Produces | Consumes |",
|
|
417
|
+
"|------|----|----------|----------|",
|
|
418
|
+
].join("\n"),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
422
|
+
insertMilestone({ id: "M001", title: "No Slices", status: "active" });
|
|
423
|
+
|
|
424
|
+
invalidateAllCaches();
|
|
425
|
+
const state = await deriveStateFromDb(base);
|
|
426
|
+
// 0-slice ROADMAP guard: should NOT derive validating-milestone (#2667)
|
|
427
|
+
assert.notEqual(
|
|
428
|
+
state.phase,
|
|
429
|
+
"validating-milestone",
|
|
430
|
+
"0-slice ROADMAP must NOT produce validating-milestone",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("corrupt VALIDATION frontmatter: extractVerdict returns undefined", () => {
|
|
435
|
+
// Test the verdict parser directly with malformed content
|
|
436
|
+
assert.equal(extractVerdict(""), undefined, "empty string → undefined");
|
|
437
|
+
assert.equal(extractVerdict("---\n\n---\n# No verdict"), undefined, "empty frontmatter → undefined");
|
|
438
|
+
assert.equal(extractVerdict("---\nverdict:\n---"), undefined, "verdict with no value → undefined");
|
|
439
|
+
assert.equal(
|
|
440
|
+
extractVerdict("random text without frontmatter"),
|
|
441
|
+
undefined,
|
|
442
|
+
"no frontmatter → undefined",
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("VALIDATION with binary/garbage content: isValidationTerminal returns false", () => {
|
|
447
|
+
assert.equal(isValidationTerminal(""), false, "empty → not terminal");
|
|
448
|
+
assert.equal(isValidationTerminal("\x00\xFF\xFE"), false, "binary → not terminal");
|
|
449
|
+
assert.equal(
|
|
450
|
+
isValidationTerminal("---\ngarbage: yes\n---\nNo verdict here."),
|
|
451
|
+
false,
|
|
452
|
+
"no verdict field → not terminal",
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
458
|
+
// SECTION 2: Transition Boundary Failures
|
|
459
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
describe("transition boundary failures", () => {
|
|
462
|
+
let base: string;
|
|
463
|
+
|
|
464
|
+
afterEach(() => {
|
|
465
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
466
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("mid-transition: CONTEXT.md created between derives transitions needs-discussion → pre-planning correctly", async () => {
|
|
470
|
+
base = makeTempDir();
|
|
471
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
472
|
+
mkdirSync(mDir, { recursive: true });
|
|
473
|
+
|
|
474
|
+
// Start with only CONTEXT-DRAFT → needs-discussion
|
|
475
|
+
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\nSome draft.\n");
|
|
476
|
+
|
|
477
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
478
|
+
invalidateAllCaches();
|
|
479
|
+
const state1 = await deriveState(base);
|
|
480
|
+
assert.equal(state1.phase, "needs-discussion");
|
|
481
|
+
|
|
482
|
+
// Now write the full CONTEXT (simulates discussion completion)
|
|
483
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Resolved\n\n## Purpose\nDone.\n");
|
|
484
|
+
|
|
485
|
+
invalidateAllCaches();
|
|
486
|
+
const state2 = await deriveState(base);
|
|
487
|
+
// Should advance to pre-planning (has context but no roadmap yet)
|
|
488
|
+
assert.equal(state2.phase, "pre-planning");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("cascading slice dependencies: S02 depends S01, S03 depends S02 — only S01 eligible", async () => {
|
|
492
|
+
base = makeTempDir();
|
|
493
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
494
|
+
|
|
495
|
+
// Create 3 slices with chain deps
|
|
496
|
+
for (const sid of ["S01", "S02", "S03"]) {
|
|
497
|
+
const sDir = join(mDir, "slices", sid, "tasks");
|
|
498
|
+
mkdirSync(sDir, { recursive: true });
|
|
499
|
+
writeFileSync(
|
|
500
|
+
join(mDir, "slices", sid, `${sid}-PLAN.md`),
|
|
501
|
+
[
|
|
502
|
+
`# ${sid}: Feature`,
|
|
503
|
+
"",
|
|
504
|
+
"**Goal:** Do the thing.",
|
|
505
|
+
"",
|
|
506
|
+
"## Tasks",
|
|
507
|
+
"",
|
|
508
|
+
"- [ ] **T01: Task** `est:30m`",
|
|
509
|
+
" - Do: Implement",
|
|
510
|
+
" - Verify: Run tests",
|
|
511
|
+
].join("\n"),
|
|
512
|
+
);
|
|
513
|
+
writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest deps.\n");
|
|
517
|
+
writeFileSync(
|
|
518
|
+
join(mDir, "M001-ROADMAP.md"),
|
|
519
|
+
[
|
|
520
|
+
"# M001: Chain Deps",
|
|
521
|
+
"",
|
|
522
|
+
"## Vision",
|
|
523
|
+
"Test cascading.",
|
|
524
|
+
"",
|
|
525
|
+
"## Success Criteria",
|
|
526
|
+
"- Works",
|
|
527
|
+
"",
|
|
528
|
+
"## Slices",
|
|
529
|
+
"",
|
|
530
|
+
"- [ ] **S01: Base** `risk:low` `depends:[]`",
|
|
531
|
+
" - After this: Base done.",
|
|
532
|
+
"",
|
|
533
|
+
"- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
|
|
534
|
+
" - After this: Middle done.",
|
|
535
|
+
"",
|
|
536
|
+
"- [ ] **S03: Top** `risk:low` `depends:[S02]`",
|
|
537
|
+
" - After this: Top done.",
|
|
538
|
+
"",
|
|
539
|
+
"## Boundary Map",
|
|
540
|
+
"",
|
|
541
|
+
"| From | To | Produces | Consumes |",
|
|
542
|
+
"|------|----|----------|----------|",
|
|
543
|
+
"| S01 | S02 | base | nothing |",
|
|
544
|
+
"| S02 | S03 | middle | base |",
|
|
545
|
+
"| S03 | terminal | top | middle |",
|
|
546
|
+
].join("\n"),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
550
|
+
insertMilestone({ id: "M001", title: "Chain", status: "active" });
|
|
551
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "pending", depends: [] });
|
|
552
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
|
|
553
|
+
insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
|
|
554
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
555
|
+
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
|
|
556
|
+
insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
|
|
557
|
+
|
|
558
|
+
invalidateAllCaches();
|
|
559
|
+
const state = await deriveStateFromDb(base);
|
|
560
|
+
|
|
561
|
+
// Only S01 should be active — S02 and S03 are dep-blocked
|
|
562
|
+
assert.equal(state.activeSlice?.id, "S01", "S01 should be the active slice (no deps)");
|
|
563
|
+
assert.equal(state.phase, "executing", "should be executing S01");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("cascading deps: completing S01 unblocks S02 (not S03)", async () => {
|
|
567
|
+
base = makeTempDir();
|
|
568
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
569
|
+
for (const sid of ["S01", "S02", "S03"]) {
|
|
570
|
+
const sDir = join(mDir, "slices", sid, "tasks");
|
|
571
|
+
mkdirSync(sDir, { recursive: true });
|
|
572
|
+
writeFileSync(
|
|
573
|
+
join(mDir, "slices", sid, `${sid}-PLAN.md`),
|
|
574
|
+
`# ${sid}\n\n**Goal:** Do.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:30m\`\n - Do: Impl\n - Verify: Test\n`,
|
|
575
|
+
);
|
|
576
|
+
writeFileSync(join(sDir, "T01-PLAN.md"), `# T01 Plan\nDo it.\n`);
|
|
577
|
+
}
|
|
578
|
+
// Write slice SUMMARY for S01
|
|
579
|
+
writeFileSync(
|
|
580
|
+
join(mDir, "slices", "S01", "S01-SUMMARY.md"),
|
|
581
|
+
"---\n---\n# S01 Summary\nDone.\n",
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest.\n");
|
|
585
|
+
writeFileSync(
|
|
586
|
+
join(mDir, "M001-ROADMAP.md"),
|
|
587
|
+
[
|
|
588
|
+
"# M001: Chain",
|
|
589
|
+
"",
|
|
590
|
+
"## Vision",
|
|
591
|
+
"Test.",
|
|
592
|
+
"",
|
|
593
|
+
"## Success Criteria",
|
|
594
|
+
"- Works",
|
|
595
|
+
"",
|
|
596
|
+
"## Slices",
|
|
597
|
+
"",
|
|
598
|
+
"- [x] **S01: Base** `risk:low` `depends:[]`",
|
|
599
|
+
" - After this: Done.",
|
|
600
|
+
"",
|
|
601
|
+
"- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
|
|
602
|
+
" - After this: Done.",
|
|
603
|
+
"",
|
|
604
|
+
"- [ ] **S03: Top** `risk:low` `depends:[S02]`",
|
|
605
|
+
" - After this: Done.",
|
|
606
|
+
"",
|
|
607
|
+
"## Boundary Map",
|
|
608
|
+
"",
|
|
609
|
+
"| From | To | Produces | Consumes |",
|
|
610
|
+
"|------|----|----------|----------|",
|
|
611
|
+
"| S01 | S02 | x | nothing |",
|
|
612
|
+
"| S02 | S03 | y | x |",
|
|
613
|
+
"| S03 | terminal | z | y |",
|
|
614
|
+
].join("\n"),
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
618
|
+
insertMilestone({ id: "M001", title: "Chain", status: "active" });
|
|
619
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "complete", depends: [] });
|
|
620
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
|
|
621
|
+
insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
|
|
622
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
623
|
+
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
|
|
624
|
+
insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
|
|
625
|
+
|
|
626
|
+
invalidateAllCaches();
|
|
627
|
+
const state = await deriveStateFromDb(base);
|
|
628
|
+
|
|
629
|
+
// S01 complete → S02 unblocked → S02 should be active
|
|
630
|
+
assert.equal(state.activeSlice?.id, "S02", "S02 should be active after S01 completes");
|
|
631
|
+
assert.equal(state.phase, "executing");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("multi-milestone deps: M002 depends M001, M003 depends M002 — blocked correctly", async () => {
|
|
635
|
+
base = createMultiMilestoneFixture();
|
|
636
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
637
|
+
insertMilestone({ id: "M001", title: "First", status: "active" });
|
|
638
|
+
insertMilestone({ id: "M002", title: "Second", status: "active", depends_on: ["M001"] });
|
|
639
|
+
insertMilestone({ id: "M003", title: "Third", status: "active", depends_on: ["M002"] });
|
|
640
|
+
|
|
641
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "S01", status: "pending" });
|
|
642
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
643
|
+
insertSlice({ id: "S01", milestoneId: "M002", title: "S01", status: "pending" });
|
|
644
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M002", status: "pending" });
|
|
645
|
+
insertSlice({ id: "S01", milestoneId: "M003", title: "S01", status: "pending" });
|
|
646
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M003", status: "pending" });
|
|
647
|
+
|
|
648
|
+
invalidateAllCaches();
|
|
649
|
+
const state = await deriveStateFromDb(base);
|
|
650
|
+
|
|
651
|
+
// Only M001 should be active — M002 and M003 are blocked
|
|
652
|
+
assert.equal(state.activeMilestone?.id, "M001", "M001 should be active (no deps)");
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("blocker_discovered in task transitions to replanning-slice", async () => {
|
|
656
|
+
base = createFullFixture();
|
|
657
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
658
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
659
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
|
|
660
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
|
|
661
|
+
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
662
|
+
|
|
663
|
+
invalidateAllCaches();
|
|
664
|
+
const state = await deriveStateFromDb(base);
|
|
665
|
+
assert.equal(state.phase, "replanning-slice", "blocker_discovered should trigger replanning");
|
|
666
|
+
assert.ok(state.blockers.length > 0, "should report blocker");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("replan loop protection: replan already done skips replanning-slice", async () => {
|
|
670
|
+
base = createFullFixture();
|
|
671
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
672
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
673
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
|
|
674
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
|
|
675
|
+
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
676
|
+
|
|
677
|
+
// Record that a replan was already done for this slice
|
|
678
|
+
insertReplanHistory({
|
|
679
|
+
milestoneId: "M001",
|
|
680
|
+
sliceId: "S01",
|
|
681
|
+
summary: "Already replanned once",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
invalidateAllCaches();
|
|
685
|
+
const state = await deriveStateFromDb(base);
|
|
686
|
+
// With replan history, should NOT re-enter replanning-slice
|
|
687
|
+
assert.notEqual(
|
|
688
|
+
state.phase,
|
|
689
|
+
"replanning-slice",
|
|
690
|
+
"replan loop protection: should not re-enter replanning after replan was done",
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("blocked state: all slices have unmet deps → blocked phase", async () => {
|
|
695
|
+
base = makeTempDir();
|
|
696
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
697
|
+
mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
|
|
698
|
+
mkdirSync(join(mDir, "slices", "S02", "tasks"), { recursive: true });
|
|
699
|
+
|
|
700
|
+
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\n## Purpose\nTest.\n");
|
|
701
|
+
writeFileSync(
|
|
702
|
+
join(mDir, "M001-ROADMAP.md"),
|
|
703
|
+
[
|
|
704
|
+
"# M001: Blocked",
|
|
705
|
+
"",
|
|
706
|
+
"## Vision",
|
|
707
|
+
"Test blocked.",
|
|
708
|
+
"",
|
|
709
|
+
"## Success Criteria",
|
|
710
|
+
"- Works",
|
|
711
|
+
"",
|
|
712
|
+
"## Slices",
|
|
713
|
+
"",
|
|
714
|
+
"- [ ] **S01: A** `risk:low` `depends:[S02]`",
|
|
715
|
+
" - After this: Done.",
|
|
716
|
+
"",
|
|
717
|
+
"- [ ] **S02: B** `risk:low` `depends:[S01]`",
|
|
718
|
+
" - After this: Done.",
|
|
719
|
+
"",
|
|
720
|
+
"## Boundary Map",
|
|
721
|
+
"",
|
|
722
|
+
"| From | To | Produces | Consumes |",
|
|
723
|
+
"|------|----|----------|----------|",
|
|
724
|
+
"| S01 | S02 | a | b |",
|
|
725
|
+
"| S02 | S01 | b | a |",
|
|
726
|
+
].join("\n"),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
730
|
+
insertMilestone({ id: "M001", title: "Blocked", status: "active" });
|
|
731
|
+
// Circular deps: S01→S02 and S02→S01 — both blocked
|
|
732
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "A", status: "pending", depends: ["S02"] });
|
|
733
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "B", status: "pending", depends: ["S01"] });
|
|
734
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
735
|
+
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
|
|
736
|
+
|
|
737
|
+
invalidateAllCaches();
|
|
738
|
+
const state = await deriveStateFromDb(base);
|
|
739
|
+
assert.equal(state.phase, "blocked", "circular deps should produce blocked phase");
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
744
|
+
// SECTION 3: Dispatch Failure Modes
|
|
745
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
describe("dispatch failure modes", () => {
|
|
748
|
+
let base: string;
|
|
749
|
+
|
|
750
|
+
afterEach(() => {
|
|
751
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
752
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("dispatch with null activeSlice in executing phase → stop (error)", async () => {
|
|
756
|
+
base = createFullFixture();
|
|
757
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
758
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
759
|
+
|
|
760
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
761
|
+
phase: "executing",
|
|
762
|
+
activeSlice: null,
|
|
763
|
+
activeTask: { id: "T01", title: "Task" },
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// The "executing → execute-task (recover missing task plan)" rule checks activeSlice
|
|
767
|
+
// and returns missingSliceStop when null
|
|
768
|
+
const result = await resolveDispatch(ctx);
|
|
769
|
+
assert.equal(result.action, "stop", "null activeSlice in executing should stop");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("dispatch for unhandled phase → stop with diagnostic", async () => {
|
|
773
|
+
base = createFullFixture();
|
|
774
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
775
|
+
|
|
776
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
777
|
+
phase: "paused" as any,
|
|
778
|
+
activeSlice: null,
|
|
779
|
+
activeTask: null,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const result = await resolveDispatch(ctx);
|
|
783
|
+
assert.equal(result.action, "stop", "unhandled phase should produce stop action");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("dispatch: summarizing with null activeSlice → stop (error)", async () => {
|
|
787
|
+
base = createFullFixture();
|
|
788
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
789
|
+
|
|
790
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
791
|
+
phase: "summarizing",
|
|
792
|
+
activeSlice: null,
|
|
793
|
+
activeTask: null,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const result = await resolveDispatch(ctx);
|
|
797
|
+
assert.equal(result.action, "stop", "summarizing without activeSlice should stop");
|
|
798
|
+
assert.ok(
|
|
799
|
+
(result as any).reason?.includes("no active slice"),
|
|
800
|
+
"stop reason should mention missing slice",
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("dispatch: evaluating-gates without gate config → skip (gates omitted)", async () => {
|
|
805
|
+
base = createFullFixture();
|
|
806
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
807
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
808
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
|
|
809
|
+
|
|
810
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
811
|
+
phase: "evaluating-gates",
|
|
812
|
+
activeSlice: { id: "S01", title: "First" },
|
|
813
|
+
activeTask: null,
|
|
814
|
+
});
|
|
815
|
+
ctx.prefs = undefined; // No prefs → gate_evaluation not enabled
|
|
816
|
+
|
|
817
|
+
const result = await resolveDispatch(ctx);
|
|
818
|
+
// Without gate config, the rule should skip (gates omitted)
|
|
819
|
+
assert.ok(
|
|
820
|
+
result.action === "skip" || result.action === "stop",
|
|
821
|
+
`evaluating-gates without config should skip or stop, got: ${result.action}`,
|
|
822
|
+
);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("dispatch: needs-discussion → discuss-milestone dispatch", async () => {
|
|
826
|
+
base = createFullFixture();
|
|
827
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
828
|
+
|
|
829
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
830
|
+
phase: "needs-discussion",
|
|
831
|
+
activeSlice: null,
|
|
832
|
+
activeTask: null,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const result = await resolveDispatch(ctx);
|
|
836
|
+
assert.equal(result.action, "dispatch");
|
|
837
|
+
assert.equal((result as any).unitType, "discuss-milestone");
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("dispatch: complete phase → stop with info level", async () => {
|
|
841
|
+
base = createFullFixture();
|
|
842
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
843
|
+
|
|
844
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
845
|
+
phase: "complete",
|
|
846
|
+
activeSlice: null,
|
|
847
|
+
activeTask: null,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
const result = await resolveDispatch(ctx);
|
|
851
|
+
assert.equal(result.action, "stop");
|
|
852
|
+
assert.equal((result as any).level, "info");
|
|
853
|
+
assert.ok((result as any).reason?.includes("complete"), "reason should mention completion");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("dispatch rule order: first match wins for overlapping rules", () => {
|
|
857
|
+
const ruleNames = getDispatchRuleNames();
|
|
858
|
+
// Verify critical ordering constraints
|
|
859
|
+
const summarizeIdx = ruleNames.indexOf("summarizing → complete-slice");
|
|
860
|
+
const runUatIdx = ruleNames.indexOf("run-uat (post-completion)");
|
|
861
|
+
const uatGateIdx = ruleNames.indexOf("uat-verdict-gate (non-PASS blocks progression)");
|
|
862
|
+
const executeIdx = ruleNames.indexOf("executing → execute-task");
|
|
863
|
+
|
|
864
|
+
// summarizing should come before execute-task
|
|
865
|
+
assert.ok(summarizeIdx < executeIdx, "summarizing rule should precede execute-task");
|
|
866
|
+
// run-uat should come before uat-verdict-gate
|
|
867
|
+
assert.ok(runUatIdx < uatGateIdx, "run-uat should precede uat-verdict-gate");
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
872
|
+
// SECTION 4: Completion & Verification Failures
|
|
873
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
describe("completion and verification failures", () => {
|
|
876
|
+
let base: string;
|
|
877
|
+
|
|
878
|
+
afterEach(() => {
|
|
879
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
880
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("needs-remediation VALIDATION blocks milestone completion dispatch", async () => {
|
|
884
|
+
base = createFullFixture();
|
|
885
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
886
|
+
writeFileSync(
|
|
887
|
+
join(mDir, "M001-VALIDATION.md"),
|
|
888
|
+
[
|
|
889
|
+
"---",
|
|
890
|
+
"verdict: needs-remediation",
|
|
891
|
+
"remediation_round: 1",
|
|
892
|
+
"---",
|
|
893
|
+
"",
|
|
894
|
+
"# Validation",
|
|
895
|
+
"",
|
|
896
|
+
"Needs remediation work.",
|
|
897
|
+
].join("\n"),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
901
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
902
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
|
|
903
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
|
|
904
|
+
|
|
905
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
906
|
+
phase: "completing-milestone",
|
|
907
|
+
activeSlice: null,
|
|
908
|
+
activeTask: null,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const result = await resolveDispatch(ctx);
|
|
912
|
+
assert.equal(result.action, "stop", "needs-remediation should block completion");
|
|
913
|
+
assert.ok(
|
|
914
|
+
(result as any).reason?.includes("needs-remediation"),
|
|
915
|
+
"stop reason should mention needs-remediation",
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("missing slice SUMMARY blocks milestone validation dispatch", async () => {
|
|
920
|
+
base = createFullFixture();
|
|
921
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
922
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
923
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
|
|
924
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
|
|
925
|
+
// No S01-SUMMARY.md or S02-SUMMARY.md on disk
|
|
926
|
+
|
|
927
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
928
|
+
phase: "validating-milestone",
|
|
929
|
+
activeSlice: null,
|
|
930
|
+
activeTask: null,
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
const result = await resolveDispatch(ctx);
|
|
934
|
+
assert.equal(result.action, "stop", "missing SUMMARY files should block validation");
|
|
935
|
+
assert.ok(
|
|
936
|
+
(result as any).reason?.includes("missing SUMMARY"),
|
|
937
|
+
"stop reason should mention missing SUMMARY",
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("VALIDATION with pass verdict: isValidationTerminal returns true", () => {
|
|
942
|
+
const content = "---\nverdict: pass\nremediation_round: 0\n---\n# Pass\n";
|
|
943
|
+
assert.equal(isValidationTerminal(content), true);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("VALIDATION with needs-attention: isValidationTerminal returns true", () => {
|
|
947
|
+
const content = "---\nverdict: needs-attention\n---\n# Attention\n";
|
|
948
|
+
assert.equal(isValidationTerminal(content), true);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("VALIDATION with needs-remediation: isValidationTerminal returns true (terminal for loop prevention)", () => {
|
|
952
|
+
// Per #832: needs-remediation IS terminal to prevent validate-milestone loops
|
|
953
|
+
const content = "---\nverdict: needs-remediation\nremediation_round: 1\n---\n# Remediate\n";
|
|
954
|
+
assert.equal(isValidationTerminal(content), true);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("UAT verdict gate: non-PASS verdict blocks progression", () => {
|
|
958
|
+
assert.equal(isAcceptableUatVerdict("pass", undefined), true);
|
|
959
|
+
assert.equal(isAcceptableUatVerdict("passed", undefined), true);
|
|
960
|
+
assert.equal(isAcceptableUatVerdict("fail", undefined), false);
|
|
961
|
+
assert.equal(isAcceptableUatVerdict("needs-remediation", undefined), false);
|
|
962
|
+
assert.equal(isAcceptableUatVerdict("partial", undefined), false, "partial without eligible type → not acceptable");
|
|
963
|
+
assert.equal(isAcceptableUatVerdict("partial", "mixed"), true, "partial with mixed type → acceptable");
|
|
964
|
+
assert.equal(isAcceptableUatVerdict("partial", "human-experience"), true, "partial with human-experience → acceptable");
|
|
965
|
+
assert.equal(isAcceptableUatVerdict("partial", "artifact-driven"), false, "partial with artifact-driven → not acceptable");
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("milestone validation verdict schema validation", () => {
|
|
969
|
+
assert.equal(isValidMilestoneVerdict("pass"), true);
|
|
970
|
+
assert.equal(isValidMilestoneVerdict("needs-attention"), true);
|
|
971
|
+
assert.equal(isValidMilestoneVerdict("needs-remediation"), true);
|
|
972
|
+
assert.equal(isValidMilestoneVerdict("fail"), false, "fail is not a valid milestone verdict");
|
|
973
|
+
assert.equal(isValidMilestoneVerdict(""), false);
|
|
974
|
+
assert.equal(isValidMilestoneVerdict("unknown"), false);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test("all slices done + no VALIDATION → validating-milestone (not completing)", async () => {
|
|
978
|
+
base = createFullFixture();
|
|
979
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
980
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
981
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
|
|
982
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
|
|
983
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
984
|
+
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
985
|
+
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
|
|
986
|
+
|
|
987
|
+
invalidateAllCaches();
|
|
988
|
+
const state = await deriveStateFromDb(base);
|
|
989
|
+
assert.equal(
|
|
990
|
+
state.phase,
|
|
991
|
+
"validating-milestone",
|
|
992
|
+
"all slices done without VALIDATION should be validating-milestone",
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("all slices done + terminal VALIDATION + no SUMMARY → completing-milestone", async () => {
|
|
997
|
+
base = createFullFixture();
|
|
998
|
+
writeFileSync(
|
|
999
|
+
join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md"),
|
|
1000
|
+
"---\nverdict: pass\n---\n# Validation\nPassed.\n",
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
1004
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
1005
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
|
|
1006
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
|
|
1007
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
1008
|
+
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
1009
|
+
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
|
|
1010
|
+
|
|
1011
|
+
invalidateAllCaches();
|
|
1012
|
+
const state = await deriveStateFromDb(base);
|
|
1013
|
+
assert.equal(
|
|
1014
|
+
state.phase,
|
|
1015
|
+
"completing-milestone",
|
|
1016
|
+
"terminal VALIDATION without SUMMARY should be completing-milestone",
|
|
1017
|
+
);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("extractVerdict: markdown body fallback works", () => {
|
|
1021
|
+
// When LLM writes verdict in body instead of frontmatter (#2960)
|
|
1022
|
+
assert.equal(extractVerdict("# Validation\n\n**Verdict:** PASS"), "pass");
|
|
1023
|
+
assert.equal(extractVerdict("# Validation\n\n**Verdict:** ✅ PASS"), "pass");
|
|
1024
|
+
assert.equal(extractVerdict("# Validation\n\n**Verdict** needs-remediation"), "needs-remediation");
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("extractVerdict: normalizes 'passed' to 'pass'", () => {
|
|
1028
|
+
assert.equal(extractVerdict("---\nverdict: passed\n---"), "pass");
|
|
1029
|
+
assert.equal(extractVerdict("**Verdict:** passed"), "pass");
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("isClosedStatus: boundary values", () => {
|
|
1033
|
+
assert.equal(isClosedStatus("complete"), true);
|
|
1034
|
+
assert.equal(isClosedStatus("done"), true);
|
|
1035
|
+
assert.equal(isClosedStatus("skipped"), true);
|
|
1036
|
+
assert.equal(isClosedStatus("active"), false);
|
|
1037
|
+
assert.equal(isClosedStatus("pending"), false);
|
|
1038
|
+
assert.equal(isClosedStatus("in_progress"), false);
|
|
1039
|
+
assert.equal(isClosedStatus(""), false);
|
|
1040
|
+
assert.equal(isClosedStatus("COMPLETE"), false, "case-sensitive: uppercase should be false");
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1045
|
+
// SECTION 5: Ghost Milestone Edge Cases
|
|
1046
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1047
|
+
|
|
1048
|
+
describe("ghost milestone edge cases", () => {
|
|
1049
|
+
let base: string;
|
|
1050
|
+
|
|
1051
|
+
afterEach(() => {
|
|
1052
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
1053
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("empty directory with DB row is NOT a ghost (#2921)", () => {
|
|
1057
|
+
base = makeTempDir();
|
|
1058
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
1059
|
+
mkdirSync(mDir, { recursive: true });
|
|
1060
|
+
|
|
1061
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
1062
|
+
insertMilestone({ id: "M001", title: "Queued", status: "active" });
|
|
1063
|
+
|
|
1064
|
+
assert.equal(isGhostMilestone(base, "M001"), false, "DB row means not a ghost");
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test("empty directory with worktree is NOT a ghost (#2921)", () => {
|
|
1068
|
+
base = makeTempDir();
|
|
1069
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
1070
|
+
mkdirSync(mDir, { recursive: true });
|
|
1071
|
+
// Simulate worktree existence
|
|
1072
|
+
mkdirSync(join(base, ".gsd", "worktrees", "M001"), { recursive: true });
|
|
1073
|
+
|
|
1074
|
+
assert.equal(isGhostMilestone(base, "M001"), false, "worktree means not a ghost");
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test("empty directory without DB or worktree IS a ghost", () => {
|
|
1078
|
+
base = makeTempDir();
|
|
1079
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
1080
|
+
mkdirSync(mDir, { recursive: true });
|
|
1081
|
+
|
|
1082
|
+
assert.equal(isGhostMilestone(base, "M001"), true, "no DB, no worktree, no files → ghost");
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("directory with only META.json is still a ghost", () => {
|
|
1086
|
+
base = makeTempDir();
|
|
1087
|
+
const mDir = join(base, ".gsd", "milestones", "M001");
|
|
1088
|
+
mkdirSync(mDir, { recursive: true });
|
|
1089
|
+
writeFileSync(join(mDir, "META.json"), '{"created":"2026-01-01"}');
|
|
1090
|
+
|
|
1091
|
+
assert.equal(isGhostMilestone(base, "M001"), true, "META.json alone → ghost");
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
test("ghost milestones are skipped in state derivation", async () => {
|
|
1095
|
+
base = makeTempDir();
|
|
1096
|
+
const gsdDir = join(base, ".gsd", "milestones");
|
|
1097
|
+
|
|
1098
|
+
// M001 is ghost — empty dir
|
|
1099
|
+
mkdirSync(join(gsdDir, "M001"), { recursive: true });
|
|
1100
|
+
|
|
1101
|
+
// M002 is real — has CONTEXT-DRAFT
|
|
1102
|
+
mkdirSync(join(gsdDir, "M002"), { recursive: true });
|
|
1103
|
+
writeFileSync(join(gsdDir, "M002", "M002-CONTEXT-DRAFT.md"), "# Draft\nContent.\n");
|
|
1104
|
+
|
|
1105
|
+
invalidateAllCaches();
|
|
1106
|
+
const state = await deriveState(base);
|
|
1107
|
+
assert.equal(state.activeMilestone?.id, "M002", "ghost M001 skipped, M002 is active");
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1112
|
+
// SECTION 6: Dispatch Guard Integration
|
|
1113
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
describe("dispatch guard integration", () => {
|
|
1116
|
+
let base: string;
|
|
1117
|
+
|
|
1118
|
+
afterEach(() => {
|
|
1119
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
1120
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test("skip_milestone_validation preference writes pass-through VALIDATION", async () => {
|
|
1124
|
+
base = createFullFixture();
|
|
1125
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
1126
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
1127
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
|
|
1128
|
+
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
|
|
1129
|
+
// Write slice SUMMARYs so the missing SUMMARY guard doesn't fire
|
|
1130
|
+
writeFileSync(
|
|
1131
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
|
|
1132
|
+
"# S01 Summary\nDone.\n",
|
|
1133
|
+
);
|
|
1134
|
+
writeFileSync(
|
|
1135
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-SUMMARY.md"),
|
|
1136
|
+
"# S02 Summary\nDone.\n",
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
1140
|
+
phase: "validating-milestone",
|
|
1141
|
+
activeSlice: null,
|
|
1142
|
+
activeTask: null,
|
|
1143
|
+
});
|
|
1144
|
+
ctx.prefs = { phases: { skip_milestone_validation: true } } as any;
|
|
1145
|
+
|
|
1146
|
+
const result = await resolveDispatch(ctx);
|
|
1147
|
+
assert.equal(result.action, "skip", "skip_milestone_validation should produce skip action");
|
|
1148
|
+
|
|
1149
|
+
// Should have written a pass-through VALIDATION file
|
|
1150
|
+
const validationPath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
|
|
1151
|
+
assert.ok(existsSync(validationPath), "VALIDATION file should be written");
|
|
1152
|
+
const content = readFileSync(validationPath, "utf-8");
|
|
1153
|
+
assert.ok(content.includes("verdict: pass"), "should contain pass verdict");
|
|
1154
|
+
assert.ok(content.includes("skipped by preference"), "should note it was skipped");
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("rewrite-docs circuit breaker: exceeding MAX attempts resolves all overrides", async () => {
|
|
1158
|
+
base = createFullFixture();
|
|
1159
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
1160
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
1161
|
+
|
|
1162
|
+
// Write a rewrite count at the max
|
|
1163
|
+
const runtimeDir = join(base, ".gsd", "runtime");
|
|
1164
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
1165
|
+
writeFileSync(
|
|
1166
|
+
join(runtimeDir, "rewrite-count.json"),
|
|
1167
|
+
JSON.stringify({ count: 3, updatedAt: new Date().toISOString() }),
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
// Import and check
|
|
1171
|
+
const { getRewriteCount } = await import("../../auto-dispatch.ts");
|
|
1172
|
+
assert.equal(getRewriteCount(base), 3, "rewrite count should be 3");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("replanning-slice with null activeSlice → stop (error)", async () => {
|
|
1176
|
+
base = createFullFixture();
|
|
1177
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
1178
|
+
|
|
1179
|
+
const ctx = buildDispatchCtx(base, "M001", {
|
|
1180
|
+
phase: "replanning-slice",
|
|
1181
|
+
activeSlice: null,
|
|
1182
|
+
activeTask: null,
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
const result = await resolveDispatch(ctx);
|
|
1186
|
+
assert.equal(result.action, "stop", "replanning without activeSlice should stop");
|
|
1187
|
+
});
|
|
1188
|
+
});
|