gsdd-cli 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -548
- package/bin/adapters/claude.mjs +18 -8
- package/bin/adapters/opencode.mjs +3 -3
- package/bin/gsdd.mjs +13 -30
- package/bin/lib/closeout-report.mjs +32 -6
- package/bin/lib/control-map.mjs +94 -19
- package/bin/lib/global-install.mjs +629 -0
- package/bin/lib/global-manifest.mjs +122 -0
- package/bin/lib/health.mjs +2 -1
- package/bin/lib/init-flow.mjs +3 -0
- package/bin/lib/init-prompts.mjs +6 -4
- package/bin/lib/init-runtime.mjs +28 -2
- package/bin/lib/lifecycle-preflight.mjs +211 -5
- package/bin/lib/models.mjs +136 -5
- package/bin/lib/next.mjs +834 -0
- package/bin/lib/work-context.mjs +760 -0
- package/bin/lib/workflows.mjs +27 -0
- package/distilled/README.md +30 -3
- package/distilled/templates/agents.block.md +1 -1
- package/distilled/templates/agents.md +0 -1
- package/distilled/templates/codebase/architecture.md +0 -1
- package/distilled/templates/codebase/concerns.md +0 -1
- package/distilled/templates/codebase/conventions.md +0 -1
- package/distilled/templates/codebase/stack.md +0 -1
- package/distilled/templates/roadmap.md +0 -1
- package/distilled/templates/spec.md +0 -1
- package/distilled/workflows/new-project.md +1 -1
- package/docs/RUNTIME-SUPPORT.md +23 -3
- package/docs/USER-GUIDE.md +43 -4
- package/docs/VERIFICATION-DISCIPLINE.md +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
writeSync,
|
|
13
|
+
} from 'fs';
|
|
14
|
+
import { basename, dirname, join, relative, resolve } from 'path';
|
|
15
|
+
|
|
16
|
+
export const WORK_DIR_NAME = '.work';
|
|
17
|
+
|
|
18
|
+
export const NEXT_STATES = Object.freeze([
|
|
19
|
+
'ask_user',
|
|
20
|
+
'research',
|
|
21
|
+
'plan',
|
|
22
|
+
'execute',
|
|
23
|
+
'verify',
|
|
24
|
+
'audit',
|
|
25
|
+
'fix_gaps',
|
|
26
|
+
'dogfood',
|
|
27
|
+
'pause',
|
|
28
|
+
'blocked',
|
|
29
|
+
'complete',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const GRAPH_NODE_TYPES = Object.freeze([
|
|
33
|
+
'goal',
|
|
34
|
+
'milestone',
|
|
35
|
+
'phase',
|
|
36
|
+
'task',
|
|
37
|
+
'decision',
|
|
38
|
+
'question',
|
|
39
|
+
'assumption',
|
|
40
|
+
'evidence',
|
|
41
|
+
'artifact',
|
|
42
|
+
'dogfood_finding',
|
|
43
|
+
'session_summary',
|
|
44
|
+
'repo',
|
|
45
|
+
'external_context',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export const GRAPH_EDGE_TYPES = Object.freeze([
|
|
49
|
+
'belongs_to',
|
|
50
|
+
'blocks',
|
|
51
|
+
'answers',
|
|
52
|
+
'supports',
|
|
53
|
+
'contradicts',
|
|
54
|
+
'supersedes',
|
|
55
|
+
'derived_from',
|
|
56
|
+
'requires_decision',
|
|
57
|
+
'verified_by',
|
|
58
|
+
'deferred_to',
|
|
59
|
+
'references',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export const GRAPH_EVENT_TYPES = Object.freeze([
|
|
63
|
+
'node_created',
|
|
64
|
+
'node_updated',
|
|
65
|
+
'edge_created',
|
|
66
|
+
'question_answered',
|
|
67
|
+
'decision_recorded',
|
|
68
|
+
'evidence_recorded',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export const PRIVACY_LEVELS = Object.freeze(['public', 'repo', 'local_only', 'secret_risk']);
|
|
72
|
+
export const SOURCE_TYPES = Object.freeze(['chat', 'file', 'command', 'web', 'ideaspine', 'codebase-context', 'manual']);
|
|
73
|
+
|
|
74
|
+
const DEFAULT_WORK_GITIGNORE = [
|
|
75
|
+
'# Workspine local runtime state',
|
|
76
|
+
'state.json',
|
|
77
|
+
'graph/events.jsonl',
|
|
78
|
+
'graph/index.json',
|
|
79
|
+
'questions/open.json',
|
|
80
|
+
'questions/answered.jsonl',
|
|
81
|
+
'evidence/manifest.json',
|
|
82
|
+
'focus/current.md',
|
|
83
|
+
'dogfood/*.md',
|
|
84
|
+
'handoff/current.md',
|
|
85
|
+
'',
|
|
86
|
+
'# Keep durable contract/research files trackable',
|
|
87
|
+
'!goal.md',
|
|
88
|
+
'!research/',
|
|
89
|
+
'!research/**',
|
|
90
|
+
'!milestone/',
|
|
91
|
+
'!milestone/**',
|
|
92
|
+
'!.gitignore',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
|
|
96
|
+
function normalizeSlashes(value) {
|
|
97
|
+
return String(value || '').replace(/\\/g, '/');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function repoRelative(cwd, filePath) {
|
|
101
|
+
return normalizeSlashes(relative(cwd, filePath));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ensureDir(dir) {
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeFileAtomic(filePath, content) {
|
|
109
|
+
ensureDir(dirname(filePath));
|
|
110
|
+
const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
111
|
+
let fd = null;
|
|
112
|
+
try {
|
|
113
|
+
fd = openSync(tempPath, 'w');
|
|
114
|
+
writeFileSync(fd, content);
|
|
115
|
+
fsyncSync(fd);
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
fd = null;
|
|
118
|
+
renameSync(tempPath, filePath);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (fd !== null) {
|
|
121
|
+
try {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
} catch {
|
|
124
|
+
// Best effort cleanup after a failed durable write.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (existsSync(tempPath)) {
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(tempPath);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best effort cleanup after a failed durable write.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendFileDurable(filePath, content) {
|
|
139
|
+
ensureDir(dirname(filePath));
|
|
140
|
+
const fd = openSync(filePath, 'a');
|
|
141
|
+
try {
|
|
142
|
+
writeSync(fd, content);
|
|
143
|
+
fsyncSync(fd);
|
|
144
|
+
} finally {
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeJsonIfMissing(filePath, value) {
|
|
150
|
+
if (existsSync(filePath)) return false;
|
|
151
|
+
writeFileAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeTextIfMissing(filePath, value) {
|
|
156
|
+
if (existsSync(filePath)) return false;
|
|
157
|
+
writeFileAtomic(filePath, value);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readTextIfExists(filePath) {
|
|
162
|
+
if (!existsSync(filePath)) return null;
|
|
163
|
+
return readFileSync(filePath, 'utf-8');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function readJsonIfExists(filePath) {
|
|
167
|
+
if (!existsSync(filePath)) return { exists: false, ok: true, value: null, error: null };
|
|
168
|
+
try {
|
|
169
|
+
return { exists: true, ok: true, value: JSON.parse(readFileSync(filePath, 'utf-8')), error: null };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return { exists: true, ok: false, value: null, error: error.message };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createGraphEvent({ actor = 'agent', type, privacy = 'repo', source = 'command', payload = {}, now = new Date() } = {}) {
|
|
176
|
+
return {
|
|
177
|
+
id: `evt_${now.toISOString().replace(/[^0-9]/g, '')}_${Math.random().toString(36).slice(2, 8)}`,
|
|
178
|
+
created_at: now.toISOString(),
|
|
179
|
+
actor,
|
|
180
|
+
type,
|
|
181
|
+
privacy,
|
|
182
|
+
source,
|
|
183
|
+
payload,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function validateGraphEvent(event) {
|
|
188
|
+
const errors = [];
|
|
189
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
190
|
+
return ['event must be an object'];
|
|
191
|
+
}
|
|
192
|
+
for (const field of ['id', 'created_at', 'actor', 'type', 'privacy', 'source', 'payload']) {
|
|
193
|
+
if (!(field in event)) errors.push(`missing ${field}`);
|
|
194
|
+
}
|
|
195
|
+
if (event.type && !GRAPH_EVENT_TYPES.includes(event.type)) errors.push(`unsupported event type ${event.type}`);
|
|
196
|
+
if (event.privacy && !PRIVACY_LEVELS.includes(event.privacy)) errors.push(`unsupported privacy ${event.privacy}`);
|
|
197
|
+
if (event.source && !SOURCE_TYPES.includes(event.source)) errors.push(`unsupported source ${event.source}`);
|
|
198
|
+
if (event.payload && typeof event.payload !== 'object') errors.push('payload must be an object');
|
|
199
|
+
const nodeType = event.payload?.node?.type;
|
|
200
|
+
if (nodeType && !GRAPH_NODE_TYPES.includes(nodeType)) errors.push(`unsupported node type ${nodeType}`);
|
|
201
|
+
const edgeType = event.payload?.edge?.type;
|
|
202
|
+
if (edgeType && !GRAPH_EDGE_TYPES.includes(edgeType)) errors.push(`unsupported edge type ${edgeType}`);
|
|
203
|
+
return errors;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function appendGraphEvent(workDir, event) {
|
|
207
|
+
const errors = validateGraphEvent(event);
|
|
208
|
+
if (errors.length > 0) {
|
|
209
|
+
throw new Error(`invalid graph event: ${errors.join('; ')}`);
|
|
210
|
+
}
|
|
211
|
+
const eventsPath = join(workDir, 'graph', 'events.jsonl');
|
|
212
|
+
appendFileDurable(eventsPath, `${JSON.stringify(event)}\n`);
|
|
213
|
+
return event;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function readGraphEvents(workDir) {
|
|
217
|
+
const eventsPath = join(workDir, 'graph', 'events.jsonl');
|
|
218
|
+
if (!existsSync(eventsPath)) return { events: [], invalid: [] };
|
|
219
|
+
const events = [];
|
|
220
|
+
const invalid = [];
|
|
221
|
+
const lines = readFileSync(eventsPath, 'utf-8').split(/\r?\n/);
|
|
222
|
+
lines.forEach((line, index) => {
|
|
223
|
+
if (!line.trim()) return;
|
|
224
|
+
try {
|
|
225
|
+
const event = JSON.parse(line);
|
|
226
|
+
const errors = validateGraphEvent(event);
|
|
227
|
+
if (errors.length > 0) invalid.push({ line: index + 1, errors });
|
|
228
|
+
else events.push(event);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
invalid.push({ line: index + 1, errors: [error.message] });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return { events, invalid };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function rebuildGraphIndex(workDir, { now = new Date(), write = false } = {}) {
|
|
237
|
+
const { events, invalid } = readGraphEvents(workDir);
|
|
238
|
+
const nodes = {};
|
|
239
|
+
const edges = [];
|
|
240
|
+
for (const event of events) {
|
|
241
|
+
const node = event.payload?.node;
|
|
242
|
+
if (node?.id) {
|
|
243
|
+
nodes[node.id] = {
|
|
244
|
+
...(nodes[node.id] || {}),
|
|
245
|
+
...node,
|
|
246
|
+
last_event_id: event.id,
|
|
247
|
+
updated_at: event.created_at,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const edge = event.payload?.edge;
|
|
251
|
+
if (edge?.from && edge?.to && edge?.type) {
|
|
252
|
+
edges.push({ ...edge, event_id: event.id, created_at: event.created_at });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const index = {
|
|
256
|
+
schema_version: 1,
|
|
257
|
+
rebuilt_at: now.toISOString(),
|
|
258
|
+
event_count: events.length,
|
|
259
|
+
invalid_event_count: invalid.length,
|
|
260
|
+
nodes,
|
|
261
|
+
edges,
|
|
262
|
+
invalid_events: invalid,
|
|
263
|
+
};
|
|
264
|
+
if (write) {
|
|
265
|
+
const indexPath = join(workDir, 'graph', 'index.json');
|
|
266
|
+
writeFileAtomic(indexPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
267
|
+
}
|
|
268
|
+
return index;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function getWorkPaths(cwd = process.cwd()) {
|
|
272
|
+
const root = resolve(cwd);
|
|
273
|
+
const workDir = join(root, WORK_DIR_NAME);
|
|
274
|
+
return {
|
|
275
|
+
root,
|
|
276
|
+
workDir,
|
|
277
|
+
goal: join(workDir, 'goal.md'),
|
|
278
|
+
state: join(workDir, 'state.json'),
|
|
279
|
+
events: join(workDir, 'graph', 'events.jsonl'),
|
|
280
|
+
index: join(workDir, 'graph', 'index.json'),
|
|
281
|
+
openQuestions: join(workDir, 'questions', 'open.json'),
|
|
282
|
+
answeredQuestions: join(workDir, 'questions', 'answered.jsonl'),
|
|
283
|
+
evidenceManifest: join(workDir, 'evidence', 'manifest.json'),
|
|
284
|
+
focus: join(workDir, 'focus', 'current.md'),
|
|
285
|
+
handoff: join(workDir, 'handoff', 'current.md'),
|
|
286
|
+
milestoneDir: join(workDir, 'milestone'),
|
|
287
|
+
rootGoal: join(root, 'goal.md'),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function ensureWorkStructure(cwd = process.cwd(), { now = new Date() } = {}) {
|
|
292
|
+
const paths = getWorkPaths(cwd);
|
|
293
|
+
const created = [];
|
|
294
|
+
for (const dir of ['graph', 'decisions', 'questions', 'evidence', 'focus', 'dogfood', 'handoff', 'research']) {
|
|
295
|
+
const fullPath = join(paths.workDir, dir);
|
|
296
|
+
if (!existsSync(fullPath)) created.push(repoRelative(paths.root, fullPath));
|
|
297
|
+
ensureDir(fullPath);
|
|
298
|
+
}
|
|
299
|
+
if (writeTextIfMissing(join(paths.workDir, '.gitignore'), DEFAULT_WORK_GITIGNORE)) created.push('.work/.gitignore');
|
|
300
|
+
if (writeTextIfMissing(paths.goal, defaultGoalContent())) created.push('.work/goal.md');
|
|
301
|
+
if (writeTextIfMissing(paths.rootGoal, defaultRootGoalPointer())) created.push('goal.md');
|
|
302
|
+
if (writeJsonIfMissing(paths.state, defaultState(now))) created.push('.work/state.json');
|
|
303
|
+
if (writeJsonIfMissing(paths.openQuestions, { schema_version: 1, questions: [] })) created.push('.work/questions/open.json');
|
|
304
|
+
if (writeTextIfMissing(paths.answeredQuestions, '')) created.push('.work/questions/answered.jsonl');
|
|
305
|
+
if (writeJsonIfMissing(paths.evidenceManifest, defaultEvidenceManifest())) created.push('.work/evidence/manifest.json');
|
|
306
|
+
if (writeTextIfMissing(paths.events, '')) created.push('.work/graph/events.jsonl');
|
|
307
|
+
if (readGraphEvents(paths.workDir).events.length === 0) {
|
|
308
|
+
const event = createGraphEvent({
|
|
309
|
+
actor: 'agent',
|
|
310
|
+
type: 'node_created',
|
|
311
|
+
privacy: 'repo',
|
|
312
|
+
source: 'command',
|
|
313
|
+
payload: {
|
|
314
|
+
node: {
|
|
315
|
+
id: 'goal:active',
|
|
316
|
+
type: 'goal',
|
|
317
|
+
title: 'Active Workspine goal',
|
|
318
|
+
path: '.work/goal.md',
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
now,
|
|
322
|
+
});
|
|
323
|
+
appendGraphEvent(paths.workDir, event);
|
|
324
|
+
}
|
|
325
|
+
rebuildGraphIndex(paths.workDir, { now, write: true });
|
|
326
|
+
return { paths, created };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function defaultGoalContent() {
|
|
330
|
+
return [
|
|
331
|
+
'# Workspine Goal',
|
|
332
|
+
'',
|
|
333
|
+
'Status: draft',
|
|
334
|
+
'',
|
|
335
|
+
'Define the active milestone goal here. `gsdd next` uses this file as the canonical continuity contract.',
|
|
336
|
+
'',
|
|
337
|
+
].join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function defaultRootGoalPointer() {
|
|
341
|
+
return [
|
|
342
|
+
'# Goal Pointer',
|
|
343
|
+
'',
|
|
344
|
+
'Canonical active goal: `.work/goal.md`',
|
|
345
|
+
'',
|
|
346
|
+
].join('\n');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function defaultState(now) {
|
|
350
|
+
return {
|
|
351
|
+
schema_version: 1,
|
|
352
|
+
status: 'active',
|
|
353
|
+
current_state: 'plan',
|
|
354
|
+
updated_at: now.toISOString(),
|
|
355
|
+
loop: ['plan', 'execute', 'verify', 'audit', 'fix_gaps', 'dogfood'],
|
|
356
|
+
privacy: {
|
|
357
|
+
raw_transcript_ingestion: 'disabled',
|
|
358
|
+
mutable_state_default: 'local_only',
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function defaultEvidenceManifest() {
|
|
364
|
+
return {
|
|
365
|
+
schema_version: 1,
|
|
366
|
+
evidence: [],
|
|
367
|
+
verification: { status: 'not_started' },
|
|
368
|
+
audit: { status: 'not_started' },
|
|
369
|
+
dogfood: { status: 'not_started' },
|
|
370
|
+
privacy: {
|
|
371
|
+
raw_transcript_ingestion: 'disabled',
|
|
372
|
+
raw_artifacts_safe_to_publish: false,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function readOpenQuestions(workDir) {
|
|
378
|
+
const result = readJsonIfExists(join(workDir, 'questions', 'open.json'));
|
|
379
|
+
if (!result.ok) return { exists: result.exists, ok: false, questions: [], error: result.error };
|
|
380
|
+
const raw = result.value;
|
|
381
|
+
if (raw === null) return { exists: result.exists, ok: true, questions: [], error: null };
|
|
382
|
+
if (!Array.isArray(raw) && !Array.isArray(raw?.questions)) {
|
|
383
|
+
return {
|
|
384
|
+
exists: result.exists,
|
|
385
|
+
ok: false,
|
|
386
|
+
questions: [],
|
|
387
|
+
error: 'open questions must be an array or an object with a questions array',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const questions = Array.isArray(raw) ? raw : raw.questions;
|
|
391
|
+
return { exists: result.exists, ok: true, questions, error: null };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function writeOpenQuestions(workDir, questions) {
|
|
395
|
+
const filePath = join(workDir, 'questions', 'open.json');
|
|
396
|
+
writeFileAtomic(filePath, `${JSON.stringify({ schema_version: 1, questions }, null, 2)}\n`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function addOpenQuestion(workDir, question, { now = new Date(), replace = false } = {}) {
|
|
400
|
+
const current = readOpenQuestions(workDir);
|
|
401
|
+
if (!current.ok) throw new Error(current.error);
|
|
402
|
+
const id = question.id || `q_${now.toISOString().replace(/[^0-9]/g, '')}`;
|
|
403
|
+
const existing = current.questions.find((item) => item.id === id);
|
|
404
|
+
const entry = {
|
|
405
|
+
id,
|
|
406
|
+
question: question.question,
|
|
407
|
+
default: question.default || null,
|
|
408
|
+
rationale: question.rationale || null,
|
|
409
|
+
gate: question.gate || 'product',
|
|
410
|
+
blocking: question.blocking !== false,
|
|
411
|
+
created_at: existing?.created_at || now.toISOString(),
|
|
412
|
+
};
|
|
413
|
+
if (existing && !replace) {
|
|
414
|
+
if (sameQuestion(existing, entry)) {
|
|
415
|
+
return { status: 'unchanged', question: existing, event: null, events: [] };
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`question ${id} already exists with different content; pass --replace to overwrite it`);
|
|
418
|
+
}
|
|
419
|
+
const nextQuestions = current.questions.filter((item) => item.id !== id);
|
|
420
|
+
nextQuestions.push(entry);
|
|
421
|
+
writeOpenQuestions(workDir, nextQuestions);
|
|
422
|
+
const event = appendGraphEvent(workDir, createGraphEvent({
|
|
423
|
+
actor: 'agent',
|
|
424
|
+
type: 'node_created',
|
|
425
|
+
privacy: 'repo',
|
|
426
|
+
source: 'command',
|
|
427
|
+
payload: {
|
|
428
|
+
node: {
|
|
429
|
+
id: `question:${id}`,
|
|
430
|
+
type: 'question',
|
|
431
|
+
title: entry.question,
|
|
432
|
+
blocking: entry.blocking,
|
|
433
|
+
gate: entry.gate,
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
now,
|
|
437
|
+
}));
|
|
438
|
+
rebuildGraphIndex(workDir, { now, write: true });
|
|
439
|
+
return { status: 'ok', question: entry, event, events: [event] };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sameQuestion(left, right) {
|
|
443
|
+
return left.question === right.question &&
|
|
444
|
+
(left.default || null) === (right.default || null) &&
|
|
445
|
+
(left.rationale || null) === (right.rationale || null) &&
|
|
446
|
+
(left.gate || 'product') === (right.gate || 'product') &&
|
|
447
|
+
(left.blocking !== false) === (right.blocking !== false);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function answerQuestion(workDir, id, answer, { now = new Date() } = {}) {
|
|
451
|
+
const current = readOpenQuestions(workDir);
|
|
452
|
+
if (!current.ok) throw new Error(current.error);
|
|
453
|
+
const question = current.questions.find((item) => item.id === id);
|
|
454
|
+
if (!question) {
|
|
455
|
+
const alreadyAnswered = findAnsweredQuestion(workDir, id, answer);
|
|
456
|
+
if (alreadyAnswered) {
|
|
457
|
+
return { found: true, already_applied: true, question: alreadyAnswered, event: null, events: [] };
|
|
458
|
+
}
|
|
459
|
+
return { found: false, question: null, event: null, events: [] };
|
|
460
|
+
}
|
|
461
|
+
const remaining = current.questions.filter((item) => item.id !== id);
|
|
462
|
+
writeOpenQuestions(workDir, remaining);
|
|
463
|
+
const answered = {
|
|
464
|
+
...question,
|
|
465
|
+
answer,
|
|
466
|
+
answered_at: now.toISOString(),
|
|
467
|
+
};
|
|
468
|
+
const answeredPath = join(workDir, 'questions', 'answered.jsonl');
|
|
469
|
+
appendFileDurable(answeredPath, `${JSON.stringify(answered)}\n`);
|
|
470
|
+
const event = appendGraphEvent(workDir, createGraphEvent({
|
|
471
|
+
actor: 'user',
|
|
472
|
+
type: 'question_answered',
|
|
473
|
+
privacy: 'repo',
|
|
474
|
+
source: 'manual',
|
|
475
|
+
payload: {
|
|
476
|
+
question_id: id,
|
|
477
|
+
answer,
|
|
478
|
+
node: {
|
|
479
|
+
id: `question:${id}`,
|
|
480
|
+
type: 'question',
|
|
481
|
+
title: question.question,
|
|
482
|
+
answered: true,
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
now,
|
|
486
|
+
}));
|
|
487
|
+
const edgeEvent = appendGraphEvent(workDir, createGraphEvent({
|
|
488
|
+
actor: 'user',
|
|
489
|
+
type: 'edge_created',
|
|
490
|
+
privacy: 'repo',
|
|
491
|
+
source: 'manual',
|
|
492
|
+
payload: {
|
|
493
|
+
edge: {
|
|
494
|
+
from: `answer:${id}:${now.toISOString()}`,
|
|
495
|
+
to: `question:${id}`,
|
|
496
|
+
type: 'answers',
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
now,
|
|
500
|
+
}));
|
|
501
|
+
rebuildGraphIndex(workDir, { now, write: true });
|
|
502
|
+
return { found: true, question: answered, event, events: [event, edgeEvent] };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function findAnsweredQuestion(workDir, id, answer) {
|
|
506
|
+
const answeredPath = join(workDir, 'questions', 'answered.jsonl');
|
|
507
|
+
if (!existsSync(answeredPath)) return null;
|
|
508
|
+
const lines = readFileSync(answeredPath, 'utf-8').split(/\r?\n/);
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
if (!line.trim()) continue;
|
|
511
|
+
try {
|
|
512
|
+
const entry = JSON.parse(line);
|
|
513
|
+
if (entry.id === id && entry.answer === answer) return entry;
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function recordDecision(workDir, decision, { now = new Date(), replace = false } = {}) {
|
|
522
|
+
const id = decision.id || `decision-${now.toISOString().slice(0, 10)}`;
|
|
523
|
+
const safeId = id.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
524
|
+
const privacy = decision.privacy || 'repo';
|
|
525
|
+
if (!PRIVACY_LEVELS.includes(privacy)) {
|
|
526
|
+
throw new Error(`unsupported privacy ${privacy}`);
|
|
527
|
+
}
|
|
528
|
+
const filePath = join(workDir, 'decisions', `${safeId}.md`);
|
|
529
|
+
const existingDecision = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null;
|
|
530
|
+
const createdAt = existingDecision && !replace
|
|
531
|
+
? existingDecision.match(/^created_at:\s*(.+)$/m)?.[1]?.trim() || now.toISOString()
|
|
532
|
+
: now.toISOString();
|
|
533
|
+
const body = [
|
|
534
|
+
'---',
|
|
535
|
+
`id: ${safeId}`,
|
|
536
|
+
`created_at: ${createdAt}`,
|
|
537
|
+
`privacy: ${privacy}`,
|
|
538
|
+
decision.supersedes ? `supersedes: ${decision.supersedes}` : null,
|
|
539
|
+
'---',
|
|
540
|
+
'',
|
|
541
|
+
`# ${decision.title || safeId}`,
|
|
542
|
+
'',
|
|
543
|
+
decision.body || '',
|
|
544
|
+
'',
|
|
545
|
+
].filter((line) => line !== null).join('\n');
|
|
546
|
+
if (existingDecision && !replace) {
|
|
547
|
+
if (existingDecision === body) {
|
|
548
|
+
return { status: 'unchanged', id: safeId, path: normalizeSlashes(relative(resolve(workDir, '..'), filePath)), event: null, events: [] };
|
|
549
|
+
}
|
|
550
|
+
throw new Error(`decision ${safeId} already exists with different content; pass --replace to overwrite it`);
|
|
551
|
+
}
|
|
552
|
+
writeFileAtomic(filePath, body);
|
|
553
|
+
const event = appendGraphEvent(workDir, createGraphEvent({
|
|
554
|
+
actor: 'user',
|
|
555
|
+
type: 'decision_recorded',
|
|
556
|
+
privacy,
|
|
557
|
+
source: 'manual',
|
|
558
|
+
payload: {
|
|
559
|
+
node: {
|
|
560
|
+
id: `decision:${safeId}`,
|
|
561
|
+
type: 'decision',
|
|
562
|
+
title: decision.title || safeId,
|
|
563
|
+
path: normalizeSlashes(relative(dirname(workDir), filePath)).replace(/^\.work\//, '.work/'),
|
|
564
|
+
supersedes: decision.supersedes || null,
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
now,
|
|
568
|
+
}));
|
|
569
|
+
const events = [event];
|
|
570
|
+
if (decision.supersedes) {
|
|
571
|
+
events.push(appendGraphEvent(workDir, createGraphEvent({
|
|
572
|
+
actor: 'user',
|
|
573
|
+
type: 'edge_created',
|
|
574
|
+
privacy,
|
|
575
|
+
source: 'manual',
|
|
576
|
+
payload: {
|
|
577
|
+
edge: {
|
|
578
|
+
from: `decision:${safeId}`,
|
|
579
|
+
to: `decision:${decision.supersedes}`,
|
|
580
|
+
type: 'supersedes',
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
now,
|
|
584
|
+
})));
|
|
585
|
+
}
|
|
586
|
+
rebuildGraphIndex(workDir, { now, write: true });
|
|
587
|
+
return { status: 'ok', id: safeId, path: normalizeSlashes(relative(resolve(workDir, '..'), filePath)), event, events };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function captureDogfoodFinding(workDir, finding, { now = new Date(), replace = false } = {}) {
|
|
591
|
+
const id = finding.id || `dogfood-${now.toISOString().slice(0, 10)}`;
|
|
592
|
+
const safeId = id.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
593
|
+
const filePath = join(workDir, 'dogfood', `${safeId}.md`);
|
|
594
|
+
const existingFinding = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null;
|
|
595
|
+
const createdAt = existingFinding && !replace
|
|
596
|
+
? existingFinding.match(/^created_at:\s*(.+)$/m)?.[1]?.trim() || now.toISOString()
|
|
597
|
+
: now.toISOString();
|
|
598
|
+
const body = [
|
|
599
|
+
'---',
|
|
600
|
+
`id: ${safeId}`,
|
|
601
|
+
`created_at: ${createdAt}`,
|
|
602
|
+
'privacy: local_only',
|
|
603
|
+
finding.backlog ? `backlog: ${finding.backlog}` : null,
|
|
604
|
+
'---',
|
|
605
|
+
'',
|
|
606
|
+
`# ${finding.title || safeId}`,
|
|
607
|
+
'',
|
|
608
|
+
finding.body || '',
|
|
609
|
+
'',
|
|
610
|
+
].filter((line) => line !== null).join('\n');
|
|
611
|
+
if (existingFinding && !replace) {
|
|
612
|
+
if (existingFinding === body) {
|
|
613
|
+
return { status: 'unchanged', id: safeId, path: normalizeSlashes(relative(resolve(workDir, '..'), filePath)), event: null };
|
|
614
|
+
}
|
|
615
|
+
throw new Error(`dogfood finding ${safeId} already exists with different content; pass --replace to overwrite it`);
|
|
616
|
+
}
|
|
617
|
+
writeFileAtomic(filePath, body);
|
|
618
|
+
|
|
619
|
+
const manifestPath = join(workDir, 'evidence', 'manifest.json');
|
|
620
|
+
const manifest = readJsonIfExists(manifestPath);
|
|
621
|
+
if (manifest.ok && manifest.value) {
|
|
622
|
+
const nextManifest = {
|
|
623
|
+
...manifest.value,
|
|
624
|
+
dogfood: {
|
|
625
|
+
...(manifest.value.dogfood || {}),
|
|
626
|
+
status: 'captured',
|
|
627
|
+
last_finding: `.work/dogfood/${safeId}.md`,
|
|
628
|
+
captured_at: now.toISOString(),
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
writeFileAtomic(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const event = appendGraphEvent(workDir, createGraphEvent({
|
|
635
|
+
actor: 'agent',
|
|
636
|
+
type: 'node_created',
|
|
637
|
+
privacy: 'local_only',
|
|
638
|
+
source: 'manual',
|
|
639
|
+
payload: {
|
|
640
|
+
node: {
|
|
641
|
+
id: `dogfood:${safeId}`,
|
|
642
|
+
type: 'dogfood_finding',
|
|
643
|
+
title: finding.title || safeId,
|
|
644
|
+
path: `.work/dogfood/${safeId}.md`,
|
|
645
|
+
backlog: finding.backlog || null,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
now,
|
|
649
|
+
}));
|
|
650
|
+
rebuildGraphIndex(workDir, { now, write: true });
|
|
651
|
+
return { status: 'ok', id: safeId, path: normalizeSlashes(relative(resolve(workDir, '..'), filePath)), event };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function inspectWorkContext(cwd = process.cwd()) {
|
|
655
|
+
const paths = getWorkPaths(cwd);
|
|
656
|
+
const state = readJsonIfExists(paths.state);
|
|
657
|
+
const questions = readOpenQuestions(paths.workDir);
|
|
658
|
+
const evidence = readJsonIfExists(paths.evidenceManifest);
|
|
659
|
+
const graph = readGraphEvents(paths.workDir);
|
|
660
|
+
const planningDir = join(paths.root, '.planning');
|
|
661
|
+
const planning = {
|
|
662
|
+
exists: existsSync(planningDir),
|
|
663
|
+
has_spec: existsSync(join(planningDir, 'SPEC.md')),
|
|
664
|
+
has_roadmap: existsSync(join(planningDir, 'ROADMAP.md')),
|
|
665
|
+
has_milestones: existsSync(join(planningDir, 'MILESTONES.md')),
|
|
666
|
+
has_config: existsSync(join(planningDir, 'config.json')),
|
|
667
|
+
phases: scanPhaseEvidence(planningDir),
|
|
668
|
+
};
|
|
669
|
+
return {
|
|
670
|
+
paths,
|
|
671
|
+
exists: existsSync(paths.workDir),
|
|
672
|
+
has_goal: existsSync(paths.goal),
|
|
673
|
+
has_root_goal: existsSync(paths.rootGoal),
|
|
674
|
+
state,
|
|
675
|
+
questions,
|
|
676
|
+
evidence,
|
|
677
|
+
graph,
|
|
678
|
+
planning,
|
|
679
|
+
milestone: inspectWorkMilestone(paths.workDir),
|
|
680
|
+
focus_exists: existsSync(paths.focus),
|
|
681
|
+
handoff_exists: existsSync(paths.handoff),
|
|
682
|
+
decisions: listMarkdownFiles(join(paths.workDir, 'decisions')),
|
|
683
|
+
dogfood: listMarkdownFiles(join(paths.workDir, 'dogfood')),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function listMarkdownFiles(dir) {
|
|
688
|
+
if (!existsSync(dir)) return [];
|
|
689
|
+
return readdirSync(dir)
|
|
690
|
+
.filter((name) => name.toLowerCase().endsWith('.md'))
|
|
691
|
+
.map((name) => normalizeSlashes(join(basename(dir), name)));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function inspectWorkMilestone(workDir) {
|
|
695
|
+
const milestoneDir = join(workDir, 'milestone');
|
|
696
|
+
const roadmapPath = join(milestoneDir, 'ROADMAP.md');
|
|
697
|
+
const auditPath = join(milestoneDir, 'AUDIT.md');
|
|
698
|
+
const milestonePath = join(milestoneDir, 'MILESTONE.md');
|
|
699
|
+
const roadmap = readTextIfExists(roadmapPath);
|
|
700
|
+
const audit = readTextIfExists(auditPath);
|
|
701
|
+
const phasesDir = join(milestoneDir, 'phases');
|
|
702
|
+
const phases = [];
|
|
703
|
+
if (existsSync(phasesDir)) {
|
|
704
|
+
for (const dirName of readdirSync(phasesDir)) {
|
|
705
|
+
const dirPath = join(phasesDir, dirName);
|
|
706
|
+
let names = [];
|
|
707
|
+
try {
|
|
708
|
+
names = readdirSync(dirPath);
|
|
709
|
+
} catch {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
phases.push({
|
|
713
|
+
dir: dirName,
|
|
714
|
+
plans: names.filter((name) => /-PLAN\.md$/i.test(name)),
|
|
715
|
+
executes: names.filter((name) => /-EXECUTE\.md$/i.test(name)),
|
|
716
|
+
verifies: names.filter((name) => /-VERIFY\.md$/i.test(name)),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const roadmapPhaseLines = (roadmap || '').split(/\r?\n/).filter((line) => /^\s*-\s+\[[ x-]\]\s+\*\*Phase\s+/i.test(line));
|
|
721
|
+
const completePhaseLines = roadmapPhaseLines.filter((line) => /^\s*-\s+\[x\]/i.test(line));
|
|
722
|
+
const auditStatus = (audit || '').match(/^Status:\s*(.+)$/im)?.[1]?.trim() || null;
|
|
723
|
+
return {
|
|
724
|
+
exists: existsSync(milestoneDir),
|
|
725
|
+
has_milestone: existsSync(milestonePath),
|
|
726
|
+
has_roadmap: existsSync(roadmapPath),
|
|
727
|
+
has_audit: existsSync(auditPath),
|
|
728
|
+
phase_count: phases.length,
|
|
729
|
+
phase_packet_count: phases.reduce((count, phase) => count + phase.plans.length + phase.executes.length + phase.verifies.length, 0),
|
|
730
|
+
roadmap_phase_count: roadmapPhaseLines.length,
|
|
731
|
+
roadmap_complete_phase_count: completePhaseLines.length,
|
|
732
|
+
roadmap_all_complete: roadmapPhaseLines.length > 0 && roadmapPhaseLines.length === completePhaseLines.length,
|
|
733
|
+
audit_status: auditStatus,
|
|
734
|
+
audit_passed: Boolean(auditStatus && /^passed\b/i.test(auditStatus)),
|
|
735
|
+
phases,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function scanPhaseEvidence(planningDir) {
|
|
740
|
+
const phasesDir = join(planningDir, 'phases');
|
|
741
|
+
if (!existsSync(phasesDir)) return [];
|
|
742
|
+
const phases = [];
|
|
743
|
+
for (const dirName of readdirSync(phasesDir)) {
|
|
744
|
+
const dirPath = join(phasesDir, dirName);
|
|
745
|
+
if (!existsSync(dirPath)) continue;
|
|
746
|
+
let names = [];
|
|
747
|
+
try {
|
|
748
|
+
names = readdirSync(dirPath);
|
|
749
|
+
} catch {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
phases.push({
|
|
753
|
+
dir: dirName,
|
|
754
|
+
plans: names.filter((name) => /-PLAN\.md$/i.test(name)),
|
|
755
|
+
summaries: names.filter((name) => /-SUMMARY\.md$/i.test(name)),
|
|
756
|
+
verifications: names.filter((name) => /-VERIFICATION\.md$/i.test(name)),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return phases;
|
|
760
|
+
}
|