gsd-pi 2.70.1-dev.ec24142 → 2.71.0-dev.977c553
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/dist/resources/extensions/gsd/custom-workflow-engine.js +16 -12
- package/dist/resources/extensions/gsd/file-lock.js +60 -0
- package/dist/resources/extensions/gsd/state.js +234 -332
- package/dist/resources/extensions/gsd/workflow-events.js +25 -13
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
- 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/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- 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 +9 -9
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +202 -1
- package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +90 -2
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +57 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +249 -1
- package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +96 -2
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +65 -1
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
- package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
- package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
- package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/markdown.js +17 -1
- package/packages/pi-tui/dist/components/markdown.js.map +1 -1
- package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
- package/packages/pi-tui/src/components/markdown.ts +22 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/custom-workflow-engine.ts +19 -14
- package/src/resources/extensions/gsd/file-lock.ts +59 -0
- package/src/resources/extensions/gsd/state.ts +274 -344
- package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
- package/src/resources/extensions/gsd/tests/file-lock.test.ts +103 -0
- package/src/resources/extensions/gsd/workflow-events.ts +34 -25
- /package/dist/web/standalone/.next/static/{20e8bFnNjxQJflHNodEve → 4xyaXTn7-shVHaGMcl75o}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{20e8bFnNjxQJflHNodEve → 4xyaXTn7-shVHaGMcl75o}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// GSD Extension — Tests for extracted deriveStateFromDb helper functions
|
|
2
|
+
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
3
|
+
//
|
|
4
|
+
// Tests the composable helpers extracted from deriveStateFromDb:
|
|
5
|
+
// reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive,
|
|
6
|
+
// handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks,
|
|
7
|
+
// detectBlockers, checkReplanTrigger, checkInterruptedWork
|
|
8
|
+
//
|
|
9
|
+
// Helpers are private — exercised through deriveStateFromDb integration.
|
|
10
|
+
|
|
11
|
+
import { describe, test, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
import { invalidateStateCache, deriveStateFromDb } from '../state.ts';
|
|
18
|
+
import {
|
|
19
|
+
openDatabase,
|
|
20
|
+
closeDatabase,
|
|
21
|
+
insertMilestone,
|
|
22
|
+
insertSlice,
|
|
23
|
+
insertTask,
|
|
24
|
+
updateTaskStatus,
|
|
25
|
+
} from '../gsd-db.ts';
|
|
26
|
+
|
|
27
|
+
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function createFixtureBase(): string {
|
|
30
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-helpers-'));
|
|
31
|
+
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeFile(base: string, relativePath: string, content: string): void {
|
|
36
|
+
const full = join(base, '.gsd', relativePath);
|
|
37
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
38
|
+
writeFileSync(full, content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanup(base: string): void {
|
|
42
|
+
rmSync(base, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ROADMAP_CONTENT = `# M001: Test Milestone
|
|
46
|
+
|
|
47
|
+
**Vision:** Test helpers.
|
|
48
|
+
|
|
49
|
+
## Slices
|
|
50
|
+
|
|
51
|
+
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
|
52
|
+
> After this: Slice done.
|
|
53
|
+
|
|
54
|
+
- [ ] **S02: Second Slice** \`risk:low\` \`depends:[S01]\`
|
|
55
|
+
> After this: All done.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const PLAN_CONTENT = `# S01: First Slice
|
|
59
|
+
|
|
60
|
+
**Goal:** Test executing.
|
|
61
|
+
**Demo:** Tests pass.
|
|
62
|
+
|
|
63
|
+
## Tasks
|
|
64
|
+
|
|
65
|
+
- [ ] **T01: First Task** \`est:10m\`
|
|
66
|
+
First task description.
|
|
67
|
+
|
|
68
|
+
- [x] **T02: Done Task** \`est:10m\`
|
|
69
|
+
Already done.
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
73
|
+
// Tests
|
|
74
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
describe('derive-state-helpers', () => {
|
|
77
|
+
|
|
78
|
+
// ─── handleNoActiveMilestone: all parked ─────────────────────────────
|
|
79
|
+
test('handleNoActiveMilestone: all milestones parked returns pre-planning with unpark hint', async () => {
|
|
80
|
+
const base = createFixtureBase();
|
|
81
|
+
try {
|
|
82
|
+
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
|
|
83
|
+
writeFile(base, 'milestones/M001/M001-PARKED.md', 'Parked.');
|
|
84
|
+
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
|
|
85
|
+
writeFile(base, 'milestones/M002/M002-PARKED.md', 'Also parked.');
|
|
86
|
+
|
|
87
|
+
openDatabase(':memory:');
|
|
88
|
+
insertMilestone({ id: 'M001', title: 'First', status: 'parked' });
|
|
89
|
+
insertMilestone({ id: 'M002', title: 'Second', status: 'parked' });
|
|
90
|
+
|
|
91
|
+
invalidateStateCache();
|
|
92
|
+
const state = await deriveStateFromDb(base);
|
|
93
|
+
|
|
94
|
+
assert.equal(state.phase, 'pre-planning', 'all-parked: phase is pre-planning');
|
|
95
|
+
assert.equal(state.activeMilestone, null, 'all-parked: no active milestone');
|
|
96
|
+
assert.ok(state.nextAction.includes('parked'), 'all-parked: nextAction mentions parked');
|
|
97
|
+
assert.ok(state.nextAction.includes('unpark'), 'all-parked: nextAction hints unpark');
|
|
98
|
+
assert.equal(state.registry.length, 2, 'all-parked: both in registry');
|
|
99
|
+
assert.ok(state.registry.every(e => e.status === 'parked'), 'all-parked: all registry entries parked');
|
|
100
|
+
} finally {
|
|
101
|
+
closeDatabase();
|
|
102
|
+
cleanup(base);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── handleNoActiveMilestone: all complete with active requirements ──
|
|
107
|
+
test('handleNoActiveMilestone: all complete with unmapped requirements', async () => {
|
|
108
|
+
const base = createFixtureBase();
|
|
109
|
+
try {
|
|
110
|
+
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
|
|
111
|
+
writeFile(base, 'REQUIREMENTS.md', `# Requirements\n\n## Active\n\n### R001 — Unmapped\n- Status: active\n- Description: Not mapped.\n`);
|
|
112
|
+
|
|
113
|
+
openDatabase(':memory:');
|
|
114
|
+
insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
|
|
115
|
+
|
|
116
|
+
invalidateStateCache();
|
|
117
|
+
const state = await deriveStateFromDb(base);
|
|
118
|
+
|
|
119
|
+
assert.equal(state.phase, 'complete', 'complete-reqs: phase is complete');
|
|
120
|
+
assert.ok(state.nextAction.includes('1 active requirement'), 'complete-reqs: nextAction notes unmapped reqs');
|
|
121
|
+
assert.equal(state.requirements?.active, 1, 'complete-reqs: requirements.active = 1');
|
|
122
|
+
} finally {
|
|
123
|
+
closeDatabase();
|
|
124
|
+
cleanup(base);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with missing slice ────
|
|
129
|
+
test('resolveSliceDependencies: GSD_SLICE_LOCK pointing to non-existent slice returns blocked', async () => {
|
|
130
|
+
const base = createFixtureBase();
|
|
131
|
+
const origLock = process.env.GSD_SLICE_LOCK;
|
|
132
|
+
try {
|
|
133
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
134
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
|
135
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
|
136
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
|
137
|
+
|
|
138
|
+
openDatabase(':memory:');
|
|
139
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
140
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
|
141
|
+
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
|
142
|
+
|
|
143
|
+
process.env.GSD_SLICE_LOCK = 'S99';
|
|
144
|
+
|
|
145
|
+
invalidateStateCache();
|
|
146
|
+
const state = await deriveStateFromDb(base);
|
|
147
|
+
|
|
148
|
+
assert.equal(state.phase, 'blocked', 'slice-lock-miss: phase is blocked');
|
|
149
|
+
assert.ok(state.blockers.some(b => b.includes('GSD_SLICE_LOCK=S99')), 'slice-lock-miss: blocker mentions lock');
|
|
150
|
+
} finally {
|
|
151
|
+
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
|
|
152
|
+
else delete process.env.GSD_SLICE_LOCK;
|
|
153
|
+
closeDatabase();
|
|
154
|
+
cleanup(base);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with valid slice ──────
|
|
159
|
+
test('resolveSliceDependencies: GSD_SLICE_LOCK targeting valid slice bypasses deps', async () => {
|
|
160
|
+
const base = createFixtureBase();
|
|
161
|
+
const origLock = process.env.GSD_SLICE_LOCK;
|
|
162
|
+
try {
|
|
163
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
164
|
+
// S02 depends on S01 but we lock to S02 directly
|
|
165
|
+
writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', `# S02\n\n**Goal:** Test.\n**Demo:** Pass.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:5m\`\n Do thing.\n`);
|
|
166
|
+
writeFile(base, 'milestones/M001/slices/S02/tasks/.gitkeep', '');
|
|
167
|
+
writeFile(base, 'milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01 Plan');
|
|
168
|
+
|
|
169
|
+
openDatabase(':memory:');
|
|
170
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
171
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'pending', risk: 'low', depends: [] });
|
|
172
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
|
173
|
+
insertTask({ id: 'T01', sliceId: 'S02', milestoneId: 'M001', title: 'Task', status: 'pending' });
|
|
174
|
+
|
|
175
|
+
process.env.GSD_SLICE_LOCK = 'S02';
|
|
176
|
+
|
|
177
|
+
invalidateStateCache();
|
|
178
|
+
const state = await deriveStateFromDb(base);
|
|
179
|
+
|
|
180
|
+
assert.equal(state.activeSlice?.id, 'S02', 'slice-lock-valid: activeSlice is S02 (locked)');
|
|
181
|
+
assert.equal(state.phase, 'executing', 'slice-lock-valid: phase is executing');
|
|
182
|
+
} finally {
|
|
183
|
+
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
|
|
184
|
+
else delete process.env.GSD_SLICE_LOCK;
|
|
185
|
+
closeDatabase();
|
|
186
|
+
cleanup(base);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ─── reconcileSliceTasks: plan file imports tasks when DB empty ──────
|
|
191
|
+
test('reconcileSliceTasks: imports tasks from plan file when DB has zero tasks (#3600)', async () => {
|
|
192
|
+
const base = createFixtureBase();
|
|
193
|
+
try {
|
|
194
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
195
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
|
196
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
|
197
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
|
198
|
+
|
|
199
|
+
openDatabase(':memory:');
|
|
200
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
201
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
|
202
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
|
203
|
+
// No tasks inserted — reconcileSliceTasks should import from plan file
|
|
204
|
+
|
|
205
|
+
invalidateStateCache();
|
|
206
|
+
const state = await deriveStateFromDb(base);
|
|
207
|
+
|
|
208
|
+
// Plan has T01 (pending) and T02 (done) — reconciliation imports both
|
|
209
|
+
assert.equal(state.phase, 'executing', 'task-reconcile: phase is executing (tasks imported)');
|
|
210
|
+
assert.equal(state.activeTask?.id, 'T01', 'task-reconcile: activeTask is T01');
|
|
211
|
+
assert.equal(state.progress?.tasks?.total, 2, 'task-reconcile: total tasks = 2');
|
|
212
|
+
assert.equal(state.progress?.tasks?.done, 1, 'task-reconcile: done tasks = 1 (T02 was [x])');
|
|
213
|
+
} finally {
|
|
214
|
+
closeDatabase();
|
|
215
|
+
cleanup(base);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ─── reconcileSliceTasks: stale task reconciled from disk summary ────
|
|
220
|
+
test('reconcileSliceTasks: stale pending task reconciled to complete when disk SUMMARY exists (#2514)', async () => {
|
|
221
|
+
const base = createFixtureBase();
|
|
222
|
+
try {
|
|
223
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
224
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
|
225
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
|
226
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
|
227
|
+
// T01 has a summary on disk but DB still says pending
|
|
228
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 Summary\n\nDone on disk.');
|
|
229
|
+
|
|
230
|
+
openDatabase(':memory:');
|
|
231
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
232
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
|
233
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
|
234
|
+
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
|
235
|
+
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
|
236
|
+
|
|
237
|
+
invalidateStateCache();
|
|
238
|
+
const state = await deriveStateFromDb(base);
|
|
239
|
+
|
|
240
|
+
// T01 should have been reconciled to complete (SUMMARY exists on disk)
|
|
241
|
+
// Both tasks complete → phase should be summarizing
|
|
242
|
+
assert.equal(state.phase, 'summarizing', 'stale-task: phase is summarizing (T01 reconciled)');
|
|
243
|
+
assert.equal(state.activeTask, null, 'stale-task: no active task (all done)');
|
|
244
|
+
assert.equal(state.progress?.tasks?.done, 2, 'stale-task: tasks.done = 2');
|
|
245
|
+
} finally {
|
|
246
|
+
closeDatabase();
|
|
247
|
+
cleanup(base);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ─── detectBlockers: blocker_discovered triggers replanning ──────────
|
|
252
|
+
test('detectBlockers: task with blocker_discovered triggers replanning-slice', async () => {
|
|
253
|
+
const base = createFixtureBase();
|
|
254
|
+
try {
|
|
255
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
256
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
|
257
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
|
258
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
|
259
|
+
// T02 completed with blocker discovered — written in summary frontmatter
|
|
260
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-SUMMARY.md',
|
|
261
|
+
'---\nblocker_discovered: true\n---\n\n# T02 Summary\n\nFound a blocker.');
|
|
262
|
+
|
|
263
|
+
openDatabase(':memory:');
|
|
264
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
265
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
|
266
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
|
267
|
+
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
|
268
|
+
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
|
269
|
+
|
|
270
|
+
invalidateStateCache();
|
|
271
|
+
const state = await deriveStateFromDb(base);
|
|
272
|
+
|
|
273
|
+
assert.equal(state.phase, 'replanning-slice', 'blocker: phase is replanning-slice');
|
|
274
|
+
assert.ok(state.blockers.some(b => b.includes('T02')), 'blocker: blockers mention T02');
|
|
275
|
+
} finally {
|
|
276
|
+
closeDatabase();
|
|
277
|
+
cleanup(base);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ─── checkInterruptedWork: continue.md triggers resume hint ─────────
|
|
282
|
+
test('checkInterruptedWork: continue.md present triggers resume nextAction', async () => {
|
|
283
|
+
const base = createFixtureBase();
|
|
284
|
+
try {
|
|
285
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
286
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
|
287
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
|
288
|
+
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
|
289
|
+
writeFile(base, 'milestones/M001/slices/S01/S01-CONTINUE.md', 'Resume from here.');
|
|
290
|
+
|
|
291
|
+
openDatabase(':memory:');
|
|
292
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
293
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
|
294
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
|
295
|
+
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
|
296
|
+
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
|
297
|
+
|
|
298
|
+
invalidateStateCache();
|
|
299
|
+
const state = await deriveStateFromDb(base);
|
|
300
|
+
|
|
301
|
+
assert.equal(state.phase, 'executing', 'continue: phase is still executing');
|
|
302
|
+
assert.ok(state.nextAction.includes('Resume interrupted work'), 'continue: nextAction mentions resume');
|
|
303
|
+
assert.ok(state.nextAction.includes('continue.md'), 'continue: nextAction mentions continue.md');
|
|
304
|
+
} finally {
|
|
305
|
+
closeDatabase();
|
|
306
|
+
cleanup(base);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
|
|
311
|
+
test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
|
|
312
|
+
const base = createFixtureBase();
|
|
313
|
+
try {
|
|
314
|
+
// M001 has summary on disk but DB status is still 'active'
|
|
315
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
316
|
+
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
|
|
317
|
+
// M002 is the real active milestone
|
|
318
|
+
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
|
|
319
|
+
|
|
320
|
+
openDatabase(':memory:');
|
|
321
|
+
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
|
|
322
|
+
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
|
|
323
|
+
|
|
324
|
+
invalidateStateCache();
|
|
325
|
+
const state = await deriveStateFromDb(base);
|
|
326
|
+
|
|
327
|
+
// M001 should be complete (summary on disk), M002 should be active
|
|
328
|
+
const m1 = state.registry.find(e => e.id === 'M001');
|
|
329
|
+
assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
|
|
330
|
+
assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
|
|
331
|
+
} finally {
|
|
332
|
+
closeDatabase();
|
|
333
|
+
cleanup(base);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─── reconcileDiskToDb: disk slices synced into DB (#2533) ──────────
|
|
338
|
+
test('reconcileDiskToDb: slices in ROADMAP.md but missing from DB are auto-inserted (#2533)', async () => {
|
|
339
|
+
const base = createFixtureBase();
|
|
340
|
+
try {
|
|
341
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
|
342
|
+
|
|
343
|
+
openDatabase(':memory:');
|
|
344
|
+
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
|
345
|
+
// No slices inserted — reconcileDiskToDb should insert from roadmap
|
|
346
|
+
|
|
347
|
+
invalidateStateCache();
|
|
348
|
+
const state = await deriveStateFromDb(base);
|
|
349
|
+
|
|
350
|
+
// Slices should have been reconciled from roadmap, S01 should be the active slice
|
|
351
|
+
assert.equal(state.activeMilestone?.id, 'M001', 'slice-reconcile: M001 is active');
|
|
352
|
+
assert.equal(state.activeSlice?.id, 'S01', 'slice-reconcile: S01 reconciled and active');
|
|
353
|
+
assert.ok((state.progress?.slices?.total ?? 0) >= 2, 'slice-reconcile: at least 2 slices reconciled');
|
|
354
|
+
} finally {
|
|
355
|
+
closeDatabase();
|
|
356
|
+
cleanup(base);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─── Queue order: milestones sorted by custom queue order ───────────
|
|
361
|
+
test('deriveStateFromDb respects custom queue order from QUEUE-ORDER.json', async () => {
|
|
362
|
+
const base = createFixtureBase();
|
|
363
|
+
try {
|
|
364
|
+
// M003 should come first per queue order, M001 second
|
|
365
|
+
const queueOrder = JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() });
|
|
366
|
+
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), queueOrder);
|
|
367
|
+
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
|
|
368
|
+
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
|
|
369
|
+
writeFile(base, 'milestones/M003/M003-CONTEXT.md', '# M003\n\nContext.');
|
|
370
|
+
|
|
371
|
+
openDatabase(':memory:');
|
|
372
|
+
// Insert in natural order — queue ordering should override
|
|
373
|
+
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
|
|
374
|
+
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
|
|
375
|
+
insertMilestone({ id: 'M003', title: 'Third', status: 'active' });
|
|
376
|
+
|
|
377
|
+
invalidateStateCache();
|
|
378
|
+
const state = await deriveStateFromDb(base);
|
|
379
|
+
|
|
380
|
+
// M003 should be the active milestone (first in queue)
|
|
381
|
+
assert.equal(state.activeMilestone?.id, 'M003', 'queue-order: M003 is active (first in queue)');
|
|
382
|
+
assert.equal(state.registry[0]?.id, 'M003', 'queue-order: registry[0] is M003');
|
|
383
|
+
} finally {
|
|
384
|
+
closeDatabase();
|
|
385
|
+
cleanup(base);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ─── handleAllSlicesDone: needs-remediation re-triggers validation ──
|
|
390
|
+
test('handleAllSlicesDone: needs-remediation verdict triggers validating-milestone', async () => {
|
|
391
|
+
const base = createFixtureBase();
|
|
392
|
+
try {
|
|
393
|
+
const doneRoadmap = `# M001: Remediation Test\n\n**Vision:** Test.\n\n## Slices\n\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > Done.\n`;
|
|
394
|
+
writeFile(base, 'milestones/M001/M001-ROADMAP.md', doneRoadmap);
|
|
395
|
+
writeFile(base, 'milestones/M001/M001-VALIDATION.md',
|
|
396
|
+
'---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# Validation\nNeeds remediation.');
|
|
397
|
+
|
|
398
|
+
openDatabase(':memory:');
|
|
399
|
+
insertMilestone({ id: 'M001', title: 'Remediation Test', status: 'active' });
|
|
400
|
+
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Done', status: 'complete', risk: 'low', depends: [] });
|
|
401
|
+
|
|
402
|
+
invalidateStateCache();
|
|
403
|
+
const state = await deriveStateFromDb(base);
|
|
404
|
+
|
|
405
|
+
assert.equal(state.phase, 'validating-milestone', 'remediation: phase is validating-milestone');
|
|
406
|
+
assert.equal(state.activeMilestone?.id, 'M001', 'remediation: activeMilestone is M001');
|
|
407
|
+
} finally {
|
|
408
|
+
closeDatabase();
|
|
409
|
+
cleanup(base);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// ─── Deferred queued shell: shell milestone deferred, real one promoted ──
|
|
414
|
+
test('buildRegistryAndFindActive: queued shell deferred, later real milestone becomes active (#3470)', async () => {
|
|
415
|
+
const base = createFixtureBase();
|
|
416
|
+
try {
|
|
417
|
+
// M001: queued shell — no content, no slices
|
|
418
|
+
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
|
419
|
+
// M002: real milestone with context
|
|
420
|
+
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Real\n\nActive milestone.');
|
|
421
|
+
|
|
422
|
+
openDatabase(':memory:');
|
|
423
|
+
insertMilestone({ id: 'M001', title: 'Shell', status: 'queued' });
|
|
424
|
+
insertMilestone({ id: 'M002', title: 'Real', status: 'active' });
|
|
425
|
+
|
|
426
|
+
invalidateStateCache();
|
|
427
|
+
const state = await deriveStateFromDb(base);
|
|
428
|
+
|
|
429
|
+
// M002 should be active (M001 queued shell deferred)
|
|
430
|
+
assert.equal(state.activeMilestone?.id, 'M002', 'deferred-shell: M002 is active (shell deferred)');
|
|
431
|
+
} finally {
|
|
432
|
+
closeDatabase();
|
|
433
|
+
cleanup(base);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
|
|
8
|
+
import { withFileLock, withFileLockSync } from "../file-lock.ts";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
function hasProperLockfile(): boolean {
|
|
13
|
+
try {
|
|
14
|
+
require("proper-lockfile");
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("withFileLockSync: executes callback when file does not exist", () => {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
|
23
|
+
try {
|
|
24
|
+
const missingPath = join(dir, "missing.txt");
|
|
25
|
+
let called = 0;
|
|
26
|
+
const result = withFileLockSync(missingPath, () => {
|
|
27
|
+
called++;
|
|
28
|
+
return "ok";
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.equal(result, "ok");
|
|
32
|
+
assert.equal(called, 1, "callback should execute exactly once");
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("withFileLock: executes callback when file does not exist", async () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
|
40
|
+
try {
|
|
41
|
+
const missingPath = join(dir, "missing.txt");
|
|
42
|
+
let called = 0;
|
|
43
|
+
const result = await withFileLock(missingPath, async () => {
|
|
44
|
+
called++;
|
|
45
|
+
return "ok";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(result, "ok");
|
|
49
|
+
assert.equal(called, 1, "callback should execute exactly once");
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("withFileLockSync: falls back to unlocked callback on ELOCKED", () => {
|
|
56
|
+
if (!hasProperLockfile() || process.platform === "win32") {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const lockfile = require("proper-lockfile");
|
|
61
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
|
62
|
+
const filePath = join(dir, "locked.jsonl");
|
|
63
|
+
writeFileSync(filePath, "{}\n", "utf-8");
|
|
64
|
+
|
|
65
|
+
const release = lockfile.lockSync(filePath, { retries: 0, stale: 10000 });
|
|
66
|
+
try {
|
|
67
|
+
let called = 0;
|
|
68
|
+
const result = withFileLockSync(filePath, () => {
|
|
69
|
+
called++;
|
|
70
|
+
return "fallback-ok";
|
|
71
|
+
});
|
|
72
|
+
assert.equal(result, "fallback-ok");
|
|
73
|
+
assert.equal(called, 1, "callback should run even when lock acquisition fails");
|
|
74
|
+
} finally {
|
|
75
|
+
release();
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("withFileLock: falls back to unlocked callback on ELOCKED", async () => {
|
|
81
|
+
if (!hasProperLockfile() || process.platform === "win32") {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lockfile = require("proper-lockfile");
|
|
86
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
|
87
|
+
const filePath = join(dir, "locked.jsonl");
|
|
88
|
+
writeFileSync(filePath, "{}\n", "utf-8");
|
|
89
|
+
|
|
90
|
+
const release = await lockfile.lock(filePath, { retries: 0, stale: 10000 });
|
|
91
|
+
try {
|
|
92
|
+
let called = 0;
|
|
93
|
+
const result = await withFileLock(filePath, async () => {
|
|
94
|
+
called++;
|
|
95
|
+
return "fallback-ok";
|
|
96
|
+
});
|
|
97
|
+
assert.equal(result, "fallback-ok");
|
|
98
|
+
assert.equal(called, 1, "callback should run even when lock acquisition fails");
|
|
99
|
+
} finally {
|
|
100
|
+
await release();
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
5
|
+
import { withFileLockSync } from "./file-lock.js";
|
|
5
6
|
import { logWarning } from "./workflow-logger.js";
|
|
6
7
|
|
|
7
8
|
// ─── Session ID ───────────────────────────────────────────────────────────
|
|
@@ -127,31 +128,39 @@ export function compactMilestoneEvents(
|
|
|
127
128
|
const logPath = join(basePath, ".gsd", "event-log.jsonl");
|
|
128
129
|
const archivePath = join(basePath, ".gsd", `event-log-${milestoneId}.jsonl.archived`);
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
return withFileLockSync(logPath, () => {
|
|
132
|
+
const allEvents = readEvents(logPath);
|
|
133
|
+
|
|
134
|
+
// Single-pass partition to halve the work (per reviewer agent)
|
|
135
|
+
const toArchive: WorkflowEvent[] = [];
|
|
136
|
+
const remaining: WorkflowEvent[] = [];
|
|
137
|
+
|
|
138
|
+
for (const e of allEvents) {
|
|
139
|
+
if ((e.params as { milestoneId?: string }).milestoneId === milestoneId) {
|
|
140
|
+
toArchive.push(e);
|
|
141
|
+
} else {
|
|
142
|
+
remaining.push(e);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
137
145
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
if (toArchive.length === 0) {
|
|
147
|
+
return { archived: 0 };
|
|
148
|
+
}
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
// Write archived events to .jsonl.archived file (crash-safe)
|
|
151
|
+
atomicWriteSync(
|
|
152
|
+
archivePath,
|
|
153
|
+
toArchive.map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Truncate active log to remaining events only
|
|
157
|
+
atomicWriteSync(
|
|
158
|
+
logPath,
|
|
159
|
+
remaining.length > 0
|
|
160
|
+
? remaining.map((e) => JSON.stringify(e)).join("\n") + "\n"
|
|
161
|
+
: "",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return { archived: toArchive.length };
|
|
165
|
+
});
|
|
157
166
|
}
|
|
File without changes
|
|
File without changes
|