@haaaiawd/loom 2.0.0 → 2.1.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/cli/src/store.js CHANGED
@@ -1,626 +1,983 @@
1
- import {
2
- appendFileSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- readdirSync,
7
- renameSync,
8
- statSync,
9
- writeFileSync,
10
- } from 'node:fs';
11
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
12
- import { createHash } from 'node:crypto';
13
- import {
14
- AGENT_PROTOCOL,
15
- AGENT_ANCHOR,
16
- CAPABILITY_TEMPLATE,
17
- DESIGN_KINDS,
18
- PROJECT_TEMPLATE,
19
- designTemplate,
20
- evalConditionPrompt,
21
- evalJudgePrompt,
22
- keeperProtocol,
23
- shapingContext,
24
- } from './protocol.js';
25
-
26
- const SCHEMA_VERSION = 2;
27
- const VALID_PROJECT_STATUS = new Set(['shaping', 'ready_for_keeper', 'build_ready', 'building', 'complete']);
28
- const VALID_TASK_STATUS = new Set(['open', 'active', 'blocked', 'done']);
29
-
30
- function now() {
31
- return new Date().toISOString();
32
- }
33
-
34
- function readJson(path, label = basename(path)) {
35
- try {
36
- return JSON.parse(readFileSync(path, 'utf8'));
37
- } catch (error) {
38
- throw new Error(`${label} cannot be read: ${error.message}`);
39
- }
40
- }
41
-
42
- function atomicJson(path, value) {
43
- const temp = `${path}.tmp-${process.pid}`;
44
- writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
45
- renameSync(temp, path);
46
- }
47
-
48
- function nextId(items, prefix) {
49
- const max = items.reduce((highest, item) => {
50
- const match = new RegExp(`^${prefix}-(\\d+)$`).exec(item.id || '');
51
- return match ? Math.max(highest, Number(match[1])) : highest;
52
- }, 0);
53
- return `${prefix}-${String(max + 1).padStart(3, '0')}`;
54
- }
55
-
56
- function asObjects(values, key) {
57
- if (!values) return [];
58
- if (!Array.isArray(values)) throw new Error(`${key} must be an array`);
59
- return values.map((value) => typeof value === 'string' ? { [key]: value } : value);
60
- }
61
-
62
- export function findRoot(from = process.cwd()) {
63
- let cursor = resolve(from);
64
- while (true) {
65
- if (existsSync(join(cursor, '.loom', 'state.json'))) return cursor;
66
- const parent = dirname(cursor);
67
- if (parent === cursor) break;
68
- cursor = parent;
69
- }
70
- throw new Error('LOOM is not initialized. Run loom init in the project root.');
71
- }
72
-
73
- export function loomPaths(root = findRoot()) {
74
- const loom = join(root, '.loom');
75
- return {
76
- root,
77
- loom,
78
- state: join(loom, 'state.json'),
79
- tasks: join(loom, 'tasks.json'),
80
- project: join(loom, 'PROJECT.md'),
81
- decisions: join(loom, 'DECISIONS.md'),
82
- design: join(loom, 'design'),
83
- capabilities: join(loom, 'capabilities'),
84
- eval: join(loom, 'eval'),
85
- };
86
- }
87
-
88
- export function initProject(root = process.cwd()) {
89
- const paths = loomPathsForInit(root);
90
- if (existsSync(paths.state)) return { initialized: false, root: paths.root, reason: 'already_initialized' };
91
- if (existsSync(join(paths.loom, 'current')) || existsSync(join(paths.loom, 'v1'))) {
92
- throw new Error('A legacy LOOM project was found. Back it up and migrate deliberately; v2 will not overwrite it.');
93
- }
94
- mkdirSync(paths.design, { recursive: true });
95
- mkdirSync(paths.capabilities, { recursive: true });
96
- mkdirSync(paths.eval, { recursive: true });
97
- const state = {
98
- schema_version: SCHEMA_VERSION,
99
- project: { name: basename(paths.root), status: 'shaping', created_at: now(), updated_at: now() },
100
- understanding: { confirmed: [], assumptions: [], unresolved: [] },
101
- keeper: { status: 'not_run', attempts: [] },
102
- };
103
- atomicJson(paths.state, state);
104
- atomicJson(paths.tasks, { schema_version: SCHEMA_VERSION, tasks: [] });
105
- writeFileSync(paths.project, PROJECT_TEMPLATE, 'utf8');
106
- writeFileSync(paths.decisions, '# Decision History\n\nCurrent truth belongs in PROJECT.md and linked design documents. This file preserves consequential superseding decisions.\n', 'utf8');
107
- installAgentAnchor(paths.root);
108
- return { initialized: true, root: paths.root, files: ['.loom/PROJECT.md', '.loom/DECISIONS.md', '.loom/state.json', '.loom/tasks.json', '.loom/design/', '.loom/capabilities/'] };
109
- }
110
-
111
- function loomPathsForInit(root) {
112
- const absolute = resolve(root);
113
- const loom = join(absolute, '.loom');
114
- return { ...loomPathsShape(absolute, loom) };
115
- }
116
-
117
- function loomPathsShape(root, loom) {
118
- return {
119
- root,
120
- loom,
121
- state: join(loom, 'state.json'),
122
- tasks: join(loom, 'tasks.json'),
123
- project: join(loom, 'PROJECT.md'),
124
- decisions: join(loom, 'DECISIONS.md'),
125
- design: join(loom, 'design'),
126
- capabilities: join(loom, 'capabilities'),
127
- eval: join(loom, 'eval'),
128
- };
129
- }
130
-
131
- function installAgentAnchor(root) {
132
- const path = join(root, 'AGENTS.md');
133
- const marker = '<!-- loom:v2 -->';
134
- if (!existsSync(path)) writeFileSync(path, `${AGENT_ANCHOR}\n`, 'utf8');
135
- else if (!readFileSync(path, 'utf8').includes(marker)) appendFileSync(path, `\n${AGENT_ANCHOR}\n`, 'utf8');
136
- }
137
-
138
- export function loadProject(root = findRoot()) {
139
- const paths = loomPaths(root);
140
- mkdirSync(paths.design, { recursive: true });
141
- mkdirSync(paths.capabilities, { recursive: true });
142
- mkdirSync(paths.eval, { recursive: true });
143
- const state = readJson(paths.state, 'state.json');
144
- const taskStore = readJson(paths.tasks, 'tasks.json');
145
- validateState(state);
146
- validateTasks(taskStore.tasks);
147
- return { paths, state, taskStore };
148
- }
149
-
150
- function validateState(state) {
151
- if (state.schema_version !== SCHEMA_VERSION) throw new Error(`Unsupported schema_version ${state.schema_version}`);
152
- if (!VALID_PROJECT_STATUS.has(state.project?.status)) throw new Error('Invalid project status');
153
- for (const key of ['confirmed', 'assumptions', 'unresolved']) {
154
- if (!Array.isArray(state.understanding?.[key])) throw new Error(`understanding.${key} must be an array`);
155
- }
156
- }
157
-
158
- function validateTasks(tasks) {
159
- if (!Array.isArray(tasks)) throw new Error('tasks.json tasks must be an array');
160
- const ids = new Set();
161
- let active = 0;
162
- for (const task of tasks) {
163
- if (!task.id || ids.has(task.id)) throw new Error(`Task id is missing or duplicated: ${task.id || '<missing>'}`);
164
- ids.add(task.id);
165
- if (!task.title || !task.outcome) throw new Error(`${task.id} requires title and outcome`);
166
- if (!VALID_TASK_STATUS.has(task.status)) throw new Error(`${task.id} has invalid status ${task.status}`);
167
- if (!Array.isArray(task.done_when) || !task.done_when.length) throw new Error(`${task.id} requires done_when`);
168
- if (!Array.isArray(task.depends_on) || !Array.isArray(task.reads)) throw new Error(`${task.id} dependencies and reads must be arrays`);
169
- if (task.status === 'active') active += 1;
170
- }
171
- if (active > 1) throw new Error('Only one Task may be active');
172
- for (const task of tasks) {
173
- for (const dep of task.depends_on) if (!ids.has(dep)) throw new Error(`${task.id} depends on missing Task ${dep}`);
174
- }
175
- detectCycles(tasks);
176
- }
177
-
178
- function detectCycles(tasks) {
179
- const map = new Map(tasks.map((task) => [task.id, task.depends_on]));
180
- const visiting = new Set();
181
- const visited = new Set();
182
- function visit(id) {
183
- if (visiting.has(id)) throw new Error(`Task dependency cycle includes ${id}`);
184
- if (visited.has(id)) return;
185
- visiting.add(id);
186
- for (const dep of map.get(id) || []) visit(dep);
187
- visiting.delete(id);
188
- visited.add(id);
189
- }
190
- for (const id of map.keys()) visit(id);
191
- }
192
-
193
- export function recordUnderstanding(payload, root = findRoot()) {
194
- const { paths, state } = loadProject(root);
195
- for (const item of asObjects(payload.confirmed, 'text')) {
196
- if (!item.text) throw new Error('Confirmed fact requires text');
197
- state.understanding.confirmed.push({ id: nextId(state.understanding.confirmed, 'F'), text: item.text, source: item.source || 'conversation', at: now() });
198
- }
199
- for (const item of asObjects(payload.assumptions, 'text')) {
200
- if (!item.text) throw new Error('Assumption requires text');
201
- state.understanding.assumptions.push({ id: nextId(state.understanding.assumptions, 'A'), text: item.text, status: 'active', source: item.source || 'agent', at: now() });
202
- }
203
- for (const item of asObjects(payload.unresolved, 'question')) {
204
- if (!item.question) throw new Error('Unresolved item requires question');
205
- state.understanding.unresolved.push({ id: nextId(state.understanding.unresolved, 'Q'), question: item.question, impact: item.impact || 'medium', status: 'open', at: now() });
206
- }
207
- for (const item of payload.resolved || []) {
208
- const target = state.understanding.unresolved.find((candidate) => candidate.id === item.id);
209
- if (!target) throw new Error(`Unknown unresolved id ${item.id}`);
210
- target.status = item.status === 'skipped' ? 'skipped' : 'resolved';
211
- target.resolution = item.resolution || '';
212
- target.resolved_at = now();
213
- }
214
- for (const id of payload.retire_assumptions || []) {
215
- const target = state.understanding.assumptions.find((candidate) => candidate.id === id);
216
- if (!target) throw new Error(`Unknown assumption id ${id}`);
217
- target.status = 'retired';
218
- }
219
- if (payload.project_status) throw new Error('Project status is controlled by ready, Keeper, and Task commands');
220
- for (const decision of payload.decisions || []) appendDecision(paths.decisions, decision, state);
221
- state.project.updated_at = now();
222
- atomicJson(paths.state, state);
223
- return state.understanding;
224
- }
225
-
226
- function appendDecision(path, decision, state) {
227
- if (!decision.title || !decision.decision || !decision.rationale) throw new Error('Decision requires title, decision, and rationale');
228
- const prior = state.decision_ids || [];
229
- const id = nextId(prior.map((value) => ({ id: value })), 'D');
230
- state.decision_ids = [...prior, id];
231
- const supersedes = decision.supersedes?.length ? decision.supersedes.join(', ') : 'none';
232
- appendFileSync(path, `\n## ${id}: ${decision.title}\n\n- Current decision: ${decision.decision}\n- Rationale: ${decision.rationale}\n- Source: ${decision.source || 'conversation'}\n- Supersedes: ${supersedes}\n- Affects: ${(decision.affects || []).join(', ') || 'project-wide or not yet classified'}\n- Recorded: ${now()}\n`, 'utf8');
233
- }
234
-
235
- export function createDesign(slug, options = {}, root = findRoot()) {
236
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Design slug must use lowercase letters, numbers, and hyphens');
237
- if (!options.title) throw new Error('Design document requires --title');
238
- if (!DESIGN_KINDS.includes(options.kind)) throw new Error(`Design kind must be one of: ${DESIGN_KINDS.join(', ')}`);
239
- const { paths } = loadProject(root);
240
- const path = join(paths.design, `${slug}.md`);
241
- if (existsSync(path)) throw new Error(`Design document already exists: ${slug}`);
242
- writeFileSync(path, designTemplate({ title: options.title, kind: options.kind }), 'utf8');
243
- return { slug, kind: options.kind, path: relative(paths.root, path).replaceAll('\\', '/') };
244
- }
245
-
246
- export function listDesigns(root = findRoot()) {
247
- const { paths } = loadProject(root);
248
- return readdirSync(paths.design).filter((name) => name.endsWith('.md')).sort();
249
- }
250
-
251
- export function getDesign(slug, root = findRoot()) {
252
- const { paths } = loadProject(root);
253
- const name = slug.endsWith('.md') ? slug : `${slug}.md`;
254
- if (basename(name) !== name) throw new Error('Invalid design document name');
255
- const path = join(paths.design, name);
256
- if (!existsSync(path)) throw new Error(`Design document not found: ${slug}`);
257
- return readFileSync(path, 'utf8');
258
- }
259
-
260
- export function createCapability(slug, options = {}, root = findRoot()) {
261
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
262
- if (!options.title) throw new Error('Capability requires --title');
263
- const { paths } = loadProject(root);
264
- const path = join(paths.capabilities, `${slug}.md`);
265
- if (existsSync(path)) throw new Error(`Capability already exists: ${slug}`);
266
- writeFileSync(path, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
267
- return { slug, path: relative(paths.root, path).replaceAll('\\', '/') };
268
- }
269
-
270
- export function listCapabilities(root = findRoot()) {
271
- const { paths } = loadProject(root);
272
- return readdirSync(paths.capabilities).filter((name) => name.endsWith('.md')).sort();
273
- }
274
-
275
- export function getCapability(slug, root = findRoot()) {
276
- const { paths } = loadProject(root);
277
- const name = slug.endsWith('.md') ? slug : `${slug}.md`;
278
- if (basename(name) !== name) throw new Error('Invalid capability name');
279
- const path = join(paths.capabilities, name);
280
- if (!existsSync(path)) throw new Error(`Capability not found: ${slug}`);
281
- return readFileSync(path, 'utf8');
282
- }
283
-
284
- export function importTasks(payload, root = findRoot()) {
285
- const { paths, taskStore } = loadProject(root);
286
- const incoming = Array.isArray(payload) ? payload : payload.tasks;
287
- if (!Array.isArray(incoming) || !incoming.length) throw new Error('Task plan requires a non-empty tasks array');
288
- for (const raw of incoming) {
289
- if (raw.status && raw.status !== 'open') throw new Error('New Work Map Tasks must start open; completion requires Task evidence');
290
- const id = raw.id || nextId(taskStore.tasks, 'TASK');
291
- if (taskStore.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
292
- taskStore.tasks.push({
293
- id,
294
- title: raw.title,
295
- outcome: raw.outcome,
296
- done_when: raw.done_when || [],
297
- boundaries: raw.boundaries || [],
298
- depends_on: raw.depends_on || [],
299
- reads: raw.reads || ['.loom/PROJECT.md'],
300
- touches: raw.touches || [],
301
- status: 'open',
302
- progress: raw.progress || { completed: [], current: '', next: '' },
303
- evidence: raw.evidence || [],
304
- created_at: now(),
305
- updated_at: now(),
306
- });
307
- }
308
- validateTasks(taskStore.tasks);
309
- atomicJson(paths.tasks, taskStore);
310
- return taskSummary(taskStore.tasks);
311
- }
312
-
313
- export function taskSummary(tasks) {
314
- return {
315
- total: tasks.length,
316
- open: tasks.filter((task) => task.status === 'open').length,
317
- active: tasks.find((task) => task.status === 'active')?.id || null,
318
- blocked: tasks.filter((task) => task.status === 'blocked').length,
319
- done: tasks.filter((task) => task.status === 'done').length,
320
- };
321
- }
322
-
323
- export function getTask(id, root = findRoot()) {
324
- const { taskStore } = loadProject(root);
325
- const task = id ? taskStore.tasks.find((item) => item.id === id) : nextTask(taskStore.tasks);
326
- if (!task) throw new Error(id ? `Task not found: ${id}` : 'No executable Task is available');
327
- return task;
328
- }
329
-
330
- function nextTask(tasks) {
331
- const active = tasks.find((task) => task.status === 'active');
332
- if (active) return active;
333
- const done = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.id));
334
- return tasks.find((task) => task.status === 'open' && task.depends_on.every((id) => done.has(id))) || null;
335
- }
336
-
337
- export function updateTask(id, patch, root = findRoot()) {
338
- const { paths, taskStore } = loadProject(root);
339
- const task = taskStore.tasks.find((item) => item.id === id);
340
- if (!task) throw new Error(`Task not found: ${id}`);
341
- const allowed = ['title', 'outcome', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'progress', 'evidence'];
342
- for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
343
- Object.assign(task, patch, { updated_at: now() });
344
- validateTasks(taskStore.tasks);
345
- atomicJson(paths.tasks, taskStore);
346
- return task;
347
- }
348
-
349
- export function blockTask(id, payload, root = findRoot()) {
350
- const task = getTask(id, root);
351
- if (task.status !== 'active') throw new Error(`${id} is not active`);
352
- if (!payload.reason || payload.reason.length < 10) throw new Error('Blocking a Task requires a concrete reason');
353
- if (!Array.isArray(payload.recovery_conditions) || !payload.recovery_conditions.length) throw new Error('Blocking a Task requires recovery_conditions');
354
- const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
355
- const { paths, taskStore } = loadProject(root);
356
- const target = taskStore.tasks.find((item) => item.id === id);
357
- target.status = 'blocked';
358
- target.block = { reason: payload.reason, recovery_conditions: payload.recovery_conditions, at: now() };
359
- target.progress = { ...target.progress, current: `blocked: ${payload.reason}`, next: payload.recovery_conditions.join('; ') };
360
- target.evidence = [...target.evidence, ...evidence];
361
- target.updated_at = now();
362
- validateTasks(taskStore.tasks);
363
- atomicJson(paths.tasks, taskStore);
364
- return target;
365
- }
366
-
367
- export function reopenTask(id, options = {}, root = findRoot()) {
368
- const { paths, taskStore } = loadProject(root);
369
- if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
370
- const task = taskStore.tasks.find((item) => item.id === id);
371
- if (!task || !['blocked', 'done'].includes(task.status)) throw new Error(`${id} is neither blocked nor done`);
372
- if (task.status === 'done' && (!options.reason || options.reason.length < 10)) throw new Error('Reopening a done Task requires a concrete --reason');
373
- const priorStatus = task.status;
374
- task.status = 'open';
375
- if (priorStatus === 'done') {
376
- task.evidence.push({ type: 'completion_reopened', reason: options.reason, at: now() });
377
- task.progress = { ...task.progress, current: `completion reopened: ${options.reason}`, next: 'Re-run the Task and close every done condition with evidence.' };
378
- }
379
- task.reopened_at = now();
380
- task.updated_at = now();
381
- validateTasks(taskStore.tasks);
382
- atomicJson(paths.tasks, taskStore);
383
- const { state } = loadProject(root);
384
- if (state.project.status === 'complete') {
385
- state.project.status = 'building';
386
- state.project.updated_at = now();
387
- atomicJson(paths.state, state);
388
- }
389
- return task;
390
- }
391
-
392
- export function startTask(id, root = findRoot()) {
393
- const { paths, state, taskStore } = loadProject(root);
394
- if (!['passed', 'skipped'].includes(state.keeper.status)) throw new Error('The one-time Keeper handoff has not passed. Run loom keeper prompt.');
395
- if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
396
- const task = taskStore.tasks.find((item) => item.id === id);
397
- if (!task || task.status !== 'open') throw new Error(`${id} is not open`);
398
- const done = new Set(taskStore.tasks.filter((item) => item.status === 'done').map((item) => item.id));
399
- const missing = task.depends_on.filter((dep) => !done.has(dep));
400
- if (missing.length) throw new Error(`${id} has incomplete dependencies: ${missing.join(', ')}`);
401
- for (const ref of task.reads) readContextDocument(paths, ref);
402
- task.status = 'active';
403
- task.updated_at = now();
404
- validateTasks(taskStore.tasks);
405
- atomicJson(paths.tasks, taskStore);
406
- state.project.status = 'building';
407
- state.project.updated_at = now();
408
- atomicJson(paths.state, state);
409
- return task;
410
- }
411
-
412
- export function completeTask(id, payload, root = findRoot()) {
413
- const { paths, state, taskStore } = loadProject(root);
414
- const task = taskStore.tasks.find((item) => item.id === id);
415
- if (!task) throw new Error(`Task not found: ${id}`);
416
- if (task.status !== 'active') throw new Error(`${id} is not active`);
417
- if (!Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Completing a Task requires concrete evidence');
418
- if (!Array.isArray(payload.checks)) throw new Error('Completing a Task requires checks for every done_when criterion');
419
- const criteria = new Set(task.done_when);
420
- const seen = new Set();
421
- for (const check of payload.checks) {
422
- if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
423
- if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
424
- if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
425
- seen.add(check.criterion);
426
- }
427
- const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
428
- if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
429
- task.status = 'done';
430
- task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
431
- task.progress = { ...task.progress, current: 'complete', next: '' };
432
- task.updated_at = now();
433
- validateTasks(taskStore.tasks);
434
- atomicJson(paths.tasks, taskStore);
435
- if (taskStore.tasks.length && taskStore.tasks.every((item) => item.status === 'done')) {
436
- state.project.status = 'complete';
437
- state.project.updated_at = now();
438
- atomicJson(paths.state, state);
439
- }
440
- return task;
441
- }
442
-
443
- export function markReady(root = findRoot()) {
444
- const { paths, state, taskStore } = loadProject(root);
445
- const project = readFileSync(paths.project, 'utf8');
446
- const designs = listDesigns(root);
447
- const capabilities = listCapabilities(root);
448
- const highOpen = state.understanding.unresolved.filter((item) => item.status === 'open' && item.impact === 'high');
449
- const errors = [];
450
- if (project.includes('> This is the concise entry point') || project.length < 400) errors.push('PROJECT.md still looks like a template');
451
- if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
452
- const unfinishedDesigns = designs.filter((name) => readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.'));
453
- if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
454
- const unfinishedCapabilities = capabilities.filter((name) => readFileSync(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes'));
455
- if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
456
- if (!taskStore.tasks.length) errors.push('The initial work map is empty');
457
- if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
458
- const digest = projectDigest(paths, taskStore.tasks);
459
- const latestKeeper = state.keeper.attempts.at(-1);
460
- if (['needs_revision', 'blocked'].includes(state.keeper.status) && latestKeeper?.prepared_digest === digest) {
461
- errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
462
- }
463
- if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
464
- state.project.status = 'ready_for_keeper';
465
- state.project.updated_at = now();
466
- state.keeper.prepared_digest = digest;
467
- state.keeper.prepared_attempt = state.keeper.attempts.length + 1;
468
- atomicJson(paths.state, state);
469
- return { ready_for_keeper: true, digest: state.keeper.prepared_digest, next: 'Open a fresh Agent and run loom keeper prompt' };
470
- }
471
-
472
- function projectDigest(paths, tasks) {
473
- const hash = createHash('sha256');
474
- hash.update(readFileSync(paths.project));
475
- hash.update(readFileSync(paths.decisions));
476
- for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
477
- for (const name of readdirSync(paths.capabilities).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.capabilities, name)));
478
- hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
479
- return hash.digest('hex');
480
- }
481
-
482
- export function getKeeperPrompt(root = findRoot()) {
483
- const { state } = loadProject(root);
484
- if (state.project.status !== 'ready_for_keeper') throw new Error('Run loom project ready before Keeper handoff');
485
- return keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest });
486
- }
487
-
488
- export function recordKeeper(payload, root = findRoot()) {
489
- const { paths, state, taskStore } = loadProject(root);
490
- if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper result cannot be recorded before loom project ready');
491
- if (!['passed', 'needs_revision', 'blocked'].includes(payload.verdict)) throw new Error('Keeper verdict must be passed, needs_revision, or blocked');
492
- if (!payload.summary || !Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Keeper result requires summary and evidence');
493
- if (payload.evidence.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('Keeper evidence entries must be non-empty strings');
494
- if (payload.gaps !== undefined && !Array.isArray(payload.gaps)) throw new Error('Keeper gaps must be an array');
495
- for (const gap of payload.gaps || []) {
496
- if (typeof gap === 'string' && gap.trim()) continue;
497
- if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
498
- throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
499
- }
500
- if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
501
- if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
502
- if (!payload.prepared_digest || payload.prepared_digest !== state.keeper.prepared_digest) throw new Error('Keeper result prepared_digest does not match the current ready state');
503
- const currentDigest = projectDigest(paths, taskStore.tasks);
504
- if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
505
- const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], at: now() };
506
- state.keeper.attempts.push(attempt);
507
- state.keeper.status = payload.verdict;
508
- if (payload.verdict === 'passed') state.project.status = 'build_ready';
509
- else state.project.status = 'shaping';
510
- state.project.updated_at = now();
511
- atomicJson(paths.state, state);
512
- return state.keeper;
513
- }
514
-
515
- export function skipKeeper(reason, root = findRoot()) {
516
- if (!reason || reason.length < 10) throw new Error('Skipping Keeper requires a concrete reason');
517
- const { paths, state } = loadProject(root);
518
- if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper can only be skipped after loom project ready');
519
- state.keeper.status = 'skipped';
520
- state.keeper.skip_reason = reason;
521
- state.project.status = 'build_ready';
522
- state.project.updated_at = now();
523
- atomicJson(paths.state, state);
524
- return state.keeper;
525
- }
526
-
527
- export function compileContext(options = {}, root = findRoot()) {
528
- const { paths, state, taskStore } = loadProject(root);
529
- const designs = listDesigns(root);
530
- const capabilities = listCapabilities(root);
531
- const summary = taskSummary(taskStore.tasks);
532
- const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
533
- const blocks = [AGENT_PROTOCOL, shapingContext({ state, taskSummary: summary, capabilityNames: capabilities, designNames: designs, forKeeper: Boolean(options.keeper) })];
534
- blocks.push(`## Project whole (${relative(paths.root, paths.project).replaceAll('\\', '/')})\n\n${readFileSync(paths.project, 'utf8')}`);
535
- if (options.keeper) {
536
- blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
537
- blocks.push(`## Decision history (${relative(paths.root, paths.decisions).replaceAll('\\', '/')})\n\n${readFileSync(paths.decisions, 'utf8')}`);
538
- blocks.push(`## Work map summary\n\n${JSON.stringify(summary, null, 2)}\n\nFirst executable Task:\n\n${JSON.stringify(nextTask(taskStore.tasks), null, 2)}`);
539
- for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
540
- for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
541
- } else if (task) {
542
- blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
543
- for (const ref of task.reads) {
544
- const content = readContextDocument(paths, ref);
545
- if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
546
- }
547
- } else {
548
- blocks.push(`## On-demand project context\n\n- Decision history: .loom/DECISIONS.md (read when correction or lineage matters)\n${designs.length ? designs.map((name) => `- Design: .loom/design/${name}`).join('\n') : '- No design documents yet; split the whole according to consequential systems and decisions.'}\n${capabilities.length ? capabilities.map((name) => `- Professional capability: .loom/capabilities/${name}`).join('\n') : '- No professional capability dossiers yet; create separate field dossiers only where expertise changes the work.'}`);
549
- }
550
- return blocks.filter(Boolean).join('\n\n---\n\n');
551
- }
552
-
553
- function normalizeRef(paths, absolute) {
554
- return relative(paths.root, absolute).replaceAll('\\', '/');
555
- }
556
-
557
- function readContextDocument(paths, ref) {
558
- const normalized = ref.replaceAll('\\', '/');
559
- if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
560
- const absolute = resolve(paths.root, normalized);
561
- const rootPrefix = `${paths.root}${process.platform === 'win32' ? '\\' : '/'}`;
562
- if (!absolute.startsWith(rootPrefix) && absolute !== paths.root) throw new Error(`Unsafe context reference: ${ref}`);
563
- if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
564
- if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
565
- return readFileSync(absolute, 'utf8');
566
- }
567
-
568
- export function checkProject(root = findRoot()) {
569
- const { paths, state, taskStore } = loadProject(root);
570
- const errors = [];
571
- const warnings = [];
572
- for (const task of taskStore.tasks) {
573
- for (const ref of task.reads) {
574
- try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
575
- }
576
- }
577
- if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
578
- if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
579
- if (!listCapabilities(root).length) warnings.push('No capability dossier exists; acceptable only when specialist judgment would not change the work');
580
- if (!listDesigns(root).length) warnings.push('No design document exists; PROJECT.md should remain a concise map of the whole');
581
- for (const name of listDesigns(root)) {
582
- if (readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.')) warnings.push(`Design document still contains template instructions: ${name}`);
583
- }
584
- for (const name of listCapabilities(root)) {
585
- if (readFileSync(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes')) warnings.push(`Capability dossier still contains template instructions: ${name}`);
586
- }
587
- return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks) };
588
- }
589
-
590
- export function scaffoldEval(payload, root = findRoot()) {
591
- if (!payload.id || !payload.title || !payload.brief) throw new Error('Eval scenario requires id, title, and brief');
592
- const { paths } = loadProject(root);
593
- const slug = payload.id.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
594
- const dir = join(paths.eval, slug);
595
- if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
596
- mkdirSync(dir, { recursive: true });
597
- const manifest = {
598
- schema_version: 1,
599
- id: payload.id,
600
- title: payload.title,
601
- brief: payload.brief,
602
- primary_comparison: 'same capable Agent without LOOM vs with LOOM',
603
- hidden_user_facts: payload.hidden_user_facts || [],
604
- success_criteria: payload.success_criteria || [],
605
- conditions: [
606
- { id: 'baseline', framework: 'none', instruction: 'Work normally with all ordinary Agent capabilities and tools.' },
607
- { id: 'loom', framework: 'loom-v2', instruction: 'Use LOOM as invisible Agent continuity infrastructure.' },
608
- ],
609
- controls: {
610
- same_model_tools_workspace_and_budget: true,
611
- minimum_repetitions_per_condition: payload.repetitions || 3,
612
- scripted_user_answers: true,
613
- context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
614
- blind_pairwise_order_swap: true,
615
- anonymization_preserves_relative_layout: true,
616
- judge_packet_preflight_required: true,
617
- condition_output_digest_manifest: true,
618
- },
619
- measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
620
- };
621
- atomicJson(join(dir, 'manifest.json'), manifest);
622
- writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false })}\n`, 'utf8');
623
- writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true })}\n`, 'utf8');
624
- writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
625
- return { scenario: payload.id, path: relative(paths.root, dir).replaceAll('\\', '/'), controls: manifest.controls };
626
- }
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
12
+ import { createHash } from 'node:crypto';
13
+ import {
14
+ agentProtocol,
15
+ AGENT_ANCHOR,
16
+ EXECUTION_PROTOCOL,
17
+ CAPABILITY_TEMPLATE,
18
+ DESIGN_KINDS,
19
+ PROJECT_TEMPLATE,
20
+ RESEARCH_GUIDE,
21
+ STRUCTURE_TEMPLATE,
22
+ designTemplate,
23
+ evalConditionPrompt,
24
+ evalJudgePrompt,
25
+ keeperProtocol,
26
+ shapingContext,
27
+ } from './protocol.js';
28
+
29
+ const SCHEMA_VERSION = 2;
30
+ const VALID_PROJECT_STATUS = new Set(['shaping', 'ready_for_keeper', 'build_ready', 'building', 'complete']);
31
+ const VALID_TASK_STATUS = new Set(['open', 'active', 'blocked', 'done']);
32
+ let runtimePaths = null;
33
+
34
+ function now() {
35
+ return new Date().toISOString();
36
+ }
37
+
38
+ function readJson(path, label = basename(path)) {
39
+ try {
40
+ return JSON.parse(readFileSync(path, 'utf8'));
41
+ } catch (error) {
42
+ throw new Error(`${label} cannot be read: ${error.message}`);
43
+ }
44
+ }
45
+
46
+ function atomicJson(path, value) {
47
+ const temp = `${path}.tmp-${process.pid}`;
48
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
49
+ renameSync(temp, path);
50
+ }
51
+
52
+ function nextId(items, prefix) {
53
+ const max = items.reduce((highest, item) => {
54
+ const match = new RegExp(`^${prefix}-(\\d+)$`).exec(item.id || '');
55
+ return match ? Math.max(highest, Number(match[1])) : highest;
56
+ }, 0);
57
+ return `${prefix}-${String(max + 1).padStart(3, '0')}`;
58
+ }
59
+
60
+ function asObjects(values, key) {
61
+ if (!values) return [];
62
+ if (!Array.isArray(values)) throw new Error(`${key} must be an array`);
63
+ return values.map((value) => typeof value === 'string' ? { [key]: value } : value);
64
+ }
65
+
66
+ export function findRoot(from = process.cwd()) {
67
+ if (runtimePaths && existsSync(runtimePaths.state)) return runtimePaths.root;
68
+ let cursor = resolve(from);
69
+ while (true) {
70
+ if (existsSync(join(cursor, '.loom', 'state.json'))) return cursor;
71
+ const parent = dirname(cursor);
72
+ if (parent === cursor) break;
73
+ cursor = parent;
74
+ }
75
+ throw new Error('LOOM is not initialized. Run loom init in the project root.');
76
+ }
77
+
78
+ export function loomPaths(root = findRoot()) {
79
+ const absolute = resolve(root);
80
+ const loom = runtimePaths?.root === absolute ? runtimePaths.loom : join(absolute, '.loom');
81
+ return loomPathsShape(absolute, loom);
82
+ }
83
+
84
+ export function configureRuntime({ stateDir } = {}) {
85
+ if (!stateDir) {
86
+ runtimePaths = null;
87
+ return null;
88
+ }
89
+ const root = resolve(process.cwd());
90
+ const loom = resolve(stateDir);
91
+ const fromWorkspace = relative(root, loom);
92
+ if (!fromWorkspace || (!fromWorkspace.startsWith('..') && !isAbsolute(fromWorkspace))) {
93
+ throw new Error('--state-dir must be outside the scored workspace');
94
+ }
95
+ runtimePaths = loomPathsShape(root, loom);
96
+ return { workspace: root, state_dir: loom };
97
+ }
98
+
99
+ export function initProject(root = process.cwd()) {
100
+ const paths = loomPathsForInit(root);
101
+ if (existsSync(paths.state)) return { initialized: false, root: paths.root, reason: 'already_initialized' };
102
+ if (existsSync(join(paths.loom, 'current')) || existsSync(join(paths.loom, 'v1'))) {
103
+ throw new Error('A legacy LOOM project was found. Back it up and migrate deliberately; v2 will not overwrite it.');
104
+ }
105
+ mkdirSync(paths.design, { recursive: true });
106
+ mkdirSync(paths.capabilities, { recursive: true });
107
+ mkdirSync(paths.eval, { recursive: true });
108
+ const state = {
109
+ schema_version: SCHEMA_VERSION,
110
+ project: { name: basename(paths.root), status: 'shaping', created_at: now(), updated_at: now() },
111
+ understanding: { confirmed: [], assumptions: [], unresolved: [] },
112
+ keeper: { status: 'not_run', attempts: [] },
113
+ };
114
+ atomicJson(paths.state, state);
115
+ atomicJson(paths.tasks, { schema_version: SCHEMA_VERSION, tasks: [] });
116
+ atomicJson(paths.deliverables, { schema_version: SCHEMA_VERSION, deliverables: [] });
117
+ writeFileSync(paths.project, PROJECT_TEMPLATE, 'utf8');
118
+ writeFileSync(paths.structure, STRUCTURE_TEMPLATE, 'utf8');
119
+ writeFileSync(paths.decisions, '# Decision History\n\nCurrent truth belongs in PROJECT.md and linked design documents. This file preserves consequential superseding decisions.\n', 'utf8');
120
+ if (paths.loom === join(paths.root, '.loom')) installAgentAnchor(paths.root);
121
+ return { initialized: true, root: paths.root, files: ['.loom/PROJECT.md', '.loom/STRUCTURE.md', '.loom/DECISIONS.md', '.loom/state.json', '.loom/tasks.json', '.loom/deliverables.json', '.loom/design/', '.loom/capabilities/'] };
122
+ }
123
+
124
+ function loomPathsForInit(root) {
125
+ const absolute = resolve(root);
126
+ const loom = runtimePaths?.root === absolute ? runtimePaths.loom : join(absolute, '.loom');
127
+ return loomPathsShape(absolute, loom);
128
+ }
129
+
130
+ function loomPathsShape(root, loom) {
131
+ return {
132
+ root,
133
+ loom,
134
+ state: join(loom, 'state.json'),
135
+ tasks: join(loom, 'tasks.json'),
136
+ deliverables: join(loom, 'deliverables.json'),
137
+ project: join(loom, 'PROJECT.md'),
138
+ structure: join(loom, 'STRUCTURE.md'),
139
+ decisions: join(loom, 'DECISIONS.md'),
140
+ design: join(loom, 'design'),
141
+ capabilities: join(loom, 'capabilities'),
142
+ eval: join(loom, 'eval'),
143
+ };
144
+ }
145
+
146
+ function installAgentAnchor(root) {
147
+ const path = join(root, 'AGENTS.md');
148
+ const marker = '<!-- loom:v2 -->';
149
+ if (!existsSync(path)) writeFileSync(path, `${AGENT_ANCHOR}\n`, 'utf8');
150
+ else if (!readFileSync(path, 'utf8').includes(marker)) appendFileSync(path, `\n${AGENT_ANCHOR}\n`, 'utf8');
151
+ }
152
+
153
+ export function loadProject(root = findRoot()) {
154
+ const paths = loomPaths(root);
155
+ mkdirSync(paths.design, { recursive: true });
156
+ mkdirSync(paths.capabilities, { recursive: true });
157
+ mkdirSync(paths.eval, { recursive: true });
158
+ const state = readJson(paths.state, 'state.json');
159
+ const taskStore = readJson(paths.tasks, 'tasks.json');
160
+ const deliverableStore = existsSync(paths.deliverables) ? readJson(paths.deliverables, 'deliverables.json') : { schema_version: SCHEMA_VERSION, deliverables: [] };
161
+ validateState(state);
162
+ validateTasks(taskStore.tasks);
163
+ return { paths, state, taskStore, deliverableStore };
164
+ }
165
+
166
+ function validateState(state) {
167
+ if (state.schema_version !== SCHEMA_VERSION) throw new Error(`Unsupported schema_version ${state.schema_version}`);
168
+ if (!VALID_PROJECT_STATUS.has(state.project?.status)) throw new Error('Invalid project status');
169
+ for (const key of ['confirmed', 'assumptions', 'unresolved']) {
170
+ if (!Array.isArray(state.understanding?.[key])) throw new Error(`understanding.${key} must be an array`);
171
+ }
172
+ }
173
+
174
+ function validateTasks(tasks) {
175
+ if (!Array.isArray(tasks)) throw new Error('tasks.json tasks must be an array');
176
+ const ids = new Set();
177
+ let active = 0;
178
+ for (const task of tasks) {
179
+ if (!task.id || ids.has(task.id)) throw new Error(`Task id is missing or duplicated: ${task.id || '<missing>'}`);
180
+ ids.add(task.id);
181
+ if (!task.title || !task.outcome) throw new Error(`${task.id} requires title and outcome`);
182
+ if (task.outcome.length < 20) throw new Error(`${task.id} outcome must be at least 20 characters describing the observable difference`);
183
+ if (!VALID_TASK_STATUS.has(task.status)) throw new Error(`${task.id} has invalid status ${task.status}`);
184
+ if (!Array.isArray(task.depends_on) || !Array.isArray(task.reads)) throw new Error(`${task.id} dependencies and reads must be arrays`);
185
+ if (!task.reads.length) throw new Error(`${task.id} reads must list at least one specific file or artifact`);
186
+ if (!Array.isArray(task.touches) || !task.touches.length) throw new Error(`${task.id} touches must list at least one specific file or artifact`);
187
+ if (!Array.isArray(task.boundaries) || !task.boundaries.length) throw new Error(`${task.id} boundaries must list at least one thing this Task does NOT do`);
188
+ const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
189
+ const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
190
+ if (!hasAcceptance && !hasDoneWhen) throw new Error(`${task.id} requires acceptance[] (preferred) or done_when[]`);
191
+ if (hasAcceptance) {
192
+ for (const acc of task.acceptance) {
193
+ if (!acc || typeof acc !== 'object') throw new Error(`${task.id} acceptance entries must be objects`);
194
+ if (!acc.criterion || typeof acc.criterion !== 'string') throw new Error(`${task.id} acceptance entry missing criterion (what must be true for this condition to pass)`);
195
+ if (!acc.verify_by || typeof acc.verify_by !== 'string') throw new Error(`${task.id} acceptance entry missing verify_by (how to check)`);
196
+ if (acc.evidence !== undefined && typeof acc.evidence !== 'string') throw new Error(`${task.id} acceptance evidence must be a string`);
197
+ }
198
+ }
199
+ if (task.implements !== undefined && typeof task.implements !== 'string') throw new Error(`${task.id} implements must be a string referencing a design decision`);
200
+ if (task.capability_hooks !== undefined) {
201
+ if (!Array.isArray(task.capability_hooks)) throw new Error(`${task.id} capability_hooks must be an array`);
202
+ for (const hook of task.capability_hooks) {
203
+ if (!hook || typeof hook !== 'object' || typeof hook.node !== 'string' || !hook.node.includes('#')) throw new Error(`${task.id} capability_hooks entry must have a node field like "capability-slug#C1"`);
204
+ if (hook.at !== undefined && typeof hook.at !== 'string') throw new Error(`${task.id} capability_hooks at must be a string`);
205
+ if (hook.must_produce !== undefined && typeof hook.must_produce !== 'string') throw new Error(`${task.id} capability_hooks must_produce must be a string`);
206
+ }
207
+ }
208
+ if (task.covers !== undefined) {
209
+ if (!Array.isArray(task.covers)) throw new Error(`${task.id} covers must be an array of deliverable IDs`);
210
+ for (const dlvId of task.covers) if (typeof dlvId !== 'string') throw new Error(`${task.id} covers entries must be deliverable ID strings`);
211
+ }
212
+ if (task.status === 'active') active += 1;
213
+ }
214
+ if (active > 1) throw new Error('Only one Task may be active');
215
+ for (const task of tasks) {
216
+ for (const dep of task.depends_on) if (!ids.has(dep)) throw new Error(`${task.id} depends on missing Task ${dep}`);
217
+ }
218
+ detectCycles(tasks);
219
+ }
220
+
221
+ function detectCycles(tasks) {
222
+ const map = new Map(tasks.map((task) => [task.id, task.depends_on]));
223
+ const visiting = new Set();
224
+ const visited = new Set();
225
+ function visit(id) {
226
+ if (visiting.has(id)) throw new Error(`Task dependency cycle includes ${id}`);
227
+ if (visited.has(id)) return;
228
+ visiting.add(id);
229
+ for (const dep of map.get(id) || []) visit(dep);
230
+ visiting.delete(id);
231
+ visited.add(id);
232
+ }
233
+ for (const id of map.keys()) visit(id);
234
+ }
235
+
236
+ export function recordUnderstanding(payload, root = findRoot()) {
237
+ const { paths, state } = loadProject(root);
238
+ for (const item of asObjects(payload.confirmed, 'text')) {
239
+ if (!item.text) throw new Error('Confirmed fact requires text');
240
+ state.understanding.confirmed.push({ id: nextId(state.understanding.confirmed, 'F'), text: item.text, source: item.source || 'conversation', at: now() });
241
+ }
242
+ for (const item of asObjects(payload.assumptions, 'text')) {
243
+ if (!item.text) throw new Error('Assumption requires text');
244
+ state.understanding.assumptions.push({ id: nextId(state.understanding.assumptions, 'A'), text: item.text, status: 'active', source: item.source || 'agent', at: now() });
245
+ }
246
+ for (const item of asObjects(payload.unresolved, 'question')) {
247
+ if (!item.question) throw new Error('Unresolved item requires question');
248
+ state.understanding.unresolved.push({ id: nextId(state.understanding.unresolved, 'Q'), question: item.question, impact: item.impact || 'medium', status: 'open', at: now() });
249
+ }
250
+ for (const item of payload.resolved || []) {
251
+ const target = state.understanding.unresolved.find((candidate) => candidate.id === item.id);
252
+ if (!target) throw new Error(`Unknown unresolved id ${item.id}`);
253
+ target.status = item.status === 'skipped' ? 'skipped' : 'resolved';
254
+ target.resolution = item.resolution || '';
255
+ target.resolved_at = now();
256
+ }
257
+ for (const id of payload.retire_assumptions || []) {
258
+ const target = state.understanding.assumptions.find((candidate) => candidate.id === id);
259
+ if (!target) throw new Error(`Unknown assumption id ${id}`);
260
+ target.status = 'retired';
261
+ }
262
+ if (payload.project_status) throw new Error('Project status is controlled by ready, Keeper, and Task commands');
263
+ for (const decision of payload.decisions || []) appendDecision(paths.decisions, decision, state);
264
+ state.project.updated_at = now();
265
+ atomicJson(paths.state, state);
266
+ return state.understanding;
267
+ }
268
+
269
+ function appendDecision(path, decision, state) {
270
+ if (!decision.title || !decision.decision || !decision.rationale) throw new Error('Decision requires title, decision, and rationale');
271
+ const prior = state.decision_ids || [];
272
+ const id = nextId(prior.map((value) => ({ id: value })), 'D');
273
+ state.decision_ids = [...prior, id];
274
+ const supersedes = decision.supersedes?.length ? decision.supersedes.join(', ') : 'none';
275
+ appendFileSync(path, `\n## ${id}: ${decision.title}\n\n- Current decision: ${decision.decision}\n- Rationale: ${decision.rationale}\n- Source: ${decision.source || 'conversation'}\n- Supersedes: ${supersedes}\n- Affects: ${(decision.affects || []).join(', ') || 'project-wide or not yet classified'}\n- Recorded: ${now()}\n`, 'utf8');
276
+ }
277
+
278
+ export function createDesign(slug, options = {}, root = findRoot()) {
279
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Design slug must use lowercase letters, numbers, and hyphens');
280
+ if (!options.title) throw new Error('Design document requires --title');
281
+ if (!DESIGN_KINDS.includes(options.kind)) throw new Error(`Design kind must be one of: ${DESIGN_KINDS.join(', ')}`);
282
+ const { paths } = loadProject(root);
283
+ const path = join(paths.design, `${slug}.md`);
284
+ if (existsSync(path)) throw new Error(`Design document already exists: ${slug}`);
285
+ writeFileSync(path, designTemplate({ title: options.title, kind: options.kind }), 'utf8');
286
+ return { slug, kind: options.kind, path: relative(paths.root, path).replaceAll('\\', '/') };
287
+ }
288
+
289
+ export function listDesigns(root = findRoot()) {
290
+ const { paths } = loadProject(root);
291
+ return readdirSync(paths.design).filter((name) => name.endsWith('.md')).sort();
292
+ }
293
+
294
+ export function getDesign(slug, root = findRoot()) {
295
+ const { paths } = loadProject(root);
296
+ const name = slug.endsWith('.md') ? slug : `${slug}.md`;
297
+ if (basename(name) !== name) throw new Error('Invalid design document name');
298
+ const path = join(paths.design, name);
299
+ if (!existsSync(path)) throw new Error(`Design document not found: ${slug}`);
300
+ return readFileSync(path, 'utf8');
301
+ }
302
+
303
+ export function createCapability(slug, options = {}, root = findRoot()) {
304
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
305
+ if (!options.title) throw new Error('Capability requires --title');
306
+ const { paths } = loadProject(root);
307
+ const dirPath = join(paths.capabilities, slug);
308
+ const legacyPath = join(paths.capabilities, `${slug}.md`);
309
+ if (existsSync(dirPath) || existsSync(legacyPath)) throw new Error(`Capability already exists: ${slug}`);
310
+ mkdirSync(join(dirPath, 'research'), { recursive: true });
311
+ const capabilityPath = join(dirPath, 'capability.md');
312
+ writeFileSync(capabilityPath, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
313
+ atomicJson(join(dirPath, 'status.json'), { status: 'researching', title: options.title, field: options.field || '', scenario: '', confirmed_at: '', created_at: now() });
314
+ return { slug, path: relative(paths.root, capabilityPath).replaceAll('\\', '/') };
315
+ }
316
+
317
+ export function listCapabilities(root = findRoot()) {
318
+ const { paths } = loadProject(root);
319
+ const entries = readdirSync(paths.capabilities, { withFileTypes: true });
320
+ const names = entries
321
+ .filter((entry) => (entry.isDirectory() && existsSync(join(paths.capabilities, entry.name, 'capability.md'))) || (entry.isFile() && entry.name.endsWith('.md')))
322
+ .map((entry) => (entry.isDirectory() ? entry.name : entry.name))
323
+ .sort();
324
+ return names;
325
+ }
326
+
327
+ export function getCapability(slug, root = findRoot()) {
328
+ const { paths } = loadProject(root);
329
+ const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug;
330
+ if (basename(cleanSlug) !== cleanSlug) throw new Error('Invalid capability name');
331
+ const dirPath = join(paths.capabilities, cleanSlug);
332
+ const dirCapability = join(dirPath, 'capability.md');
333
+ if (existsSync(dirCapability)) return readFileSync(dirCapability, 'utf8');
334
+ const legacyPath = join(paths.capabilities, `${cleanSlug}.md`);
335
+ if (existsSync(legacyPath)) return readFileSync(legacyPath, 'utf8');
336
+ throw new Error(`Capability not found: ${slug}`);
337
+ }
338
+
339
+ export function researchCapability(slug, options = {}, root = findRoot()) {
340
+ if (!options.field) throw new Error('Research requires --field <professional-field>');
341
+ const { paths } = loadProject(root);
342
+ const dir = join(paths.capabilities, slug);
343
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}. Run loom capability add ${slug} --title <text> first.`);
344
+ const statusPath = join(dir, 'status.json');
345
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
346
+ const status = readJson(statusPath, 'status.json');
347
+ if (status.status === 'confirmed') {
348
+ status.reopened_at = now();
349
+ status.reopen_reason = options.reopen_reason || 'new evidence requires updating the capability';
350
+ }
351
+ status.field = options.field;
352
+ status.status = 'researching';
353
+ status.updated_at = now();
354
+ atomicJson(statusPath, status);
355
+ const researchDir = join(dir, 'research');
356
+ mkdirSync(researchDir, { recursive: true });
357
+ const guidePath = join(researchDir, '_guide.md');
358
+ if (!existsSync(guidePath)) writeFileSync(guidePath, RESEARCH_GUIDE, 'utf8');
359
+ return {
360
+ slug,
361
+ field: options.field,
362
+ research_dir: relative(paths.root, researchDir).replaceAll('\\', '/'),
363
+ status: 'researching',
364
+ next: `Add .md files to research/ — one per expert narrative, case study, or methodology source. See research/_guide.md for what to write. Then run loom capability synthesize ${slug}.`,
365
+ };
366
+ }
367
+
368
+ export function synthesizeCapability(slug, root = findRoot()) {
369
+ const { paths } = loadProject(root);
370
+ const dir = join(paths.capabilities, slug);
371
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
372
+ const statusPath = join(dir, 'status.json');
373
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
374
+ const status = readJson(statusPath, 'status.json');
375
+ if (status.status === 'confirmed') throw new Error(`Capability ${slug} is confirmed; run loom capability research ${slug} --field <text> to reopen it with new evidence`);
376
+ const researchDir = join(dir, 'research');
377
+ const materials = readdirSync(researchDir).filter((f) => f.endsWith('.md') && f !== '_guide.md').sort();
378
+ if (!materials.length) throw new Error(`No research materials found in ${slug}/research/. Add .md files (one per expert narrative, case study, or methodology source). See research/_guide.md for guidance. Then run loom capability synthesize ${slug}.`);
379
+ const capabilityPath = join(dir, 'capability.md');
380
+ const content = readFileSync(capabilityPath, 'utf8');
381
+ const nodePattern = /### (C\d+):/g;
382
+ const nodes = [...content.matchAll(nodePattern)].map((m) => m[1]);
383
+ if (!nodes.length) throw new Error('Capability has no decision tree nodes (### C1, C2, ...). Add nodes before synthesizing.');
384
+ const missingSources = [];
385
+ const missingCounterexamples = [];
386
+ const danglingSources = [];
387
+ const materialNames = materials.flatMap((f) => [f, f.slice(0, -3)]);
388
+ for (const node of nodes) {
389
+ const nodeSection = content.split(`### ${node}:`)[1]?.split('### ')[0] || '';
390
+ if (!nodeSection.includes('source:')) missingSources.push(node);
391
+ else {
392
+ const sourceMatch = nodeSection.match(/source:\s*(.+)(?:\n|$)/);
393
+ const cited = sourceMatch ? sourceMatch[1].trim() : '';
394
+ if (cited && !materialNames.some((name) => cited.includes(name))) danglingSources.push(node);
395
+ }
396
+ if (!nodeSection.includes('counterexample:')) missingCounterexamples.push(node);
397
+ }
398
+ if (missingSources.length) throw new Error(`Decision tree nodes missing source citations: ${missingSources.join(', ')}. Every node must reference a research material.`);
399
+ if (danglingSources.length) throw new Error(`Decision tree nodes cite sources not found in research/: ${danglingSources.join(', ')}. The source field should reference one of the research files.`);
400
+ if (missingCounterexamples.length) throw new Error(`Decision tree nodes missing counterexamples: ${missingCounterexamples.join(', ')}. Every node must have a counterexample (a situation where an expert would NOT walk this path).`);
401
+ status.status = 'synthesized';
402
+ status.updated_at = now();
403
+ atomicJson(statusPath, status);
404
+ return { slug, status: 'synthesized', nodes: nodes.length, materials: materials.length };
405
+ }
406
+
407
+ export function confirmCapability(slug, options = {}, root = findRoot()) {
408
+ if (!options.scenario || options.scenario.length < 20) throw new Error('Confirm requires --scenario <text> (at least 20 characters describing which expert situation this project most resembles)');
409
+ const { paths } = loadProject(root);
410
+ const dir = join(paths.capabilities, slug);
411
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
412
+ const statusPath = join(dir, 'status.json');
413
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
414
+ const status = readJson(statusPath, 'status.json');
415
+ if (status.status !== 'synthesized') throw new Error(`Capability ${slug} must be synthesized before confirmation. Run loom capability synthesize ${slug} first.`);
416
+ status.scenario = options.scenario;
417
+ status.status = 'confirmed';
418
+ status.confirmed_at = now();
419
+ status.updated_at = now();
420
+ atomicJson(statusPath, status);
421
+ const capabilityPath = join(dir, 'capability.md');
422
+ const content = readFileSync(capabilityPath, 'utf8');
423
+ const scenarioMatch = content.match(/(## Project scenario\n)([\s\S]*?)(\n## )/);
424
+ if (scenarioMatch) {
425
+ const prefix = scenarioMatch[1];
426
+ const suffix = scenarioMatch[3];
427
+ const blockquote = scenarioMatch[2].match(/(> [^\n]+\n)+/);
428
+ const blockquoteText = blockquote ? blockquote[0] : '';
429
+ const updated = content.replace(/## Project scenario\n[\s\S]*?\n## /, `${prefix}${blockquoteText}\n${options.scenario}\n${suffix}`);
430
+ writeFileSync(capabilityPath, updated, 'utf8');
431
+ }
432
+ return { slug, status: 'confirmed', scenario: options.scenario };
433
+ }
434
+
435
+ export function getCapabilityStatus(slug, root = findRoot()) {
436
+ const { paths } = loadProject(root);
437
+ const dir = join(paths.capabilities, slug);
438
+ const statusPath = join(dir, 'status.json');
439
+ if (!existsSync(statusPath)) return { slug, status: 'legacy' };
440
+ return { ...readJson(statusPath, 'status.json'), slug };
441
+ }
442
+
443
+ export function addDeliverable(slug, options = {}, root = findRoot()) {
444
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Deliverable slug must use lowercase letters, numbers, and hyphens');
445
+ if (!options.title) throw new Error('Deliverable requires --title');
446
+ if (!options.kind) throw new Error('Deliverable requires --kind (module, feature, behavior, interface, artifact, operational, verification, or other)');
447
+ const { paths, deliverableStore } = loadProject(root);
448
+ if (deliverableStore.deliverables.some((item) => item.slug === slug)) throw new Error(`Deliverable already exists: ${slug}`);
449
+ const id = nextId(deliverableStore.deliverables, 'DLV');
450
+ deliverableStore.deliverables.push({ id, slug, title: options.title, kind: options.kind, notes: options.notes || '', covered_by: [], created_at: now() });
451
+ atomicJson(paths.deliverables, deliverableStore);
452
+ return { id, slug, title: options.title, kind: options.kind };
453
+ }
454
+
455
+ export function listDeliverables(root = findRoot()) {
456
+ const { deliverableStore } = loadProject(root);
457
+ return deliverableStore.deliverables.map((item) => ({ id: item.id, slug: item.slug, title: item.title, kind: item.kind, covered: item.covered_by.length > 0 }));
458
+ }
459
+
460
+ export function checkDeliverableCoverage(root = findRoot()) {
461
+ const { paths, taskStore, deliverableStore } = loadProject(root);
462
+ const taskCovers = new Map();
463
+ for (const task of taskStore.tasks) {
464
+ for (const dlvId of task.covers || []) {
465
+ if (!taskCovers.has(dlvId)) taskCovers.set(dlvId, []);
466
+ taskCovers.get(dlvId).push(task.id);
467
+ }
468
+ }
469
+ const uncovered = [];
470
+ const covered = [];
471
+ for (const dlv of deliverableStore.deliverables) {
472
+ const tasks = taskCovers.get(dlv.id) || [];
473
+ if (tasks.length === 0) uncovered.push({ id: dlv.id, slug: dlv.slug, title: dlv.title });
474
+ else covered.push({ id: dlv.id, slug: dlv.slug, tasks });
475
+ }
476
+ let changed = false;
477
+ for (const dlv of deliverableStore.deliverables) {
478
+ const tasks = taskCovers.get(dlv.id) || [];
479
+ const sorted = [...tasks].sort();
480
+ const current = JSON.stringify(dlv.covered_by);
481
+ const newVal = JSON.stringify(sorted);
482
+ if (current !== newVal) { dlv.covered_by = sorted; changed = true; }
483
+ }
484
+ if (changed) atomicJson(paths.deliverables, deliverableStore);
485
+ return { total: deliverableStore.deliverables.length, covered: covered.length, uncovered: uncovered.length, uncovered_items: uncovered, covered_items: covered };
486
+ }
487
+
488
+ function capabilityPath(paths, name) {
489
+ const dirCapability = join(paths.capabilities, name, 'capability.md');
490
+ if (existsSync(dirCapability)) return dirCapability;
491
+ return join(paths.capabilities, name);
492
+ }
493
+
494
+ function capabilityTemplateResidue(content) {
495
+ return content.includes('Name the established field, what expertise it contributes')
496
+ || content.includes('<node name>')
497
+ || content.includes('<which research material or expert narrative supports this node>');
498
+ }
499
+
500
+ function extractCapabilityNode(paths, nodeRef) {
501
+ const parts = nodeRef.split('#');
502
+ if (parts.length !== 2) return null;
503
+ const [slug, nodeId] = parts;
504
+ const capPath = capabilityPath(paths, slug);
505
+ if (!existsSync(capPath)) return null;
506
+ const content = readFileSync(capPath, 'utf8');
507
+ const header = `### ${nodeId}:`;
508
+ const headerIndex = content.indexOf(header);
509
+ if (headerIndex === -1) return null;
510
+ const afterHeader = content.slice(headerIndex + header.length);
511
+ const nextNode = afterHeader.search(/### C\d+:/);
512
+ const nextSection = afterHeader.search(/\n## /);
513
+ let endIdx = afterHeader.length;
514
+ if (nextNode !== -1) endIdx = Math.min(endIdx, nextNode);
515
+ if (nextSection !== -1) endIdx = Math.min(endIdx, nextSection);
516
+ return `${header}${afterHeader.slice(0, endIdx)}`;
517
+ }
518
+
519
+ export function importTasks(payload, root = findRoot()) {
520
+ const { paths, taskStore } = loadProject(root);
521
+ const incoming = Array.isArray(payload) ? payload : payload.tasks;
522
+ if (!Array.isArray(incoming) || !incoming.length) throw new Error('Task plan requires a non-empty tasks array');
523
+ for (const raw of incoming) {
524
+ if (raw.status && raw.status !== 'open') throw new Error('New Work Map Tasks must start open; completion requires Task evidence');
525
+ const id = raw.id || nextId(taskStore.tasks, 'TASK');
526
+ if (taskStore.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
527
+ taskStore.tasks.push({
528
+ id,
529
+ title: raw.title,
530
+ outcome: raw.outcome,
531
+ acceptance: raw.acceptance || [],
532
+ done_when: raw.done_when || [],
533
+ boundaries: raw.boundaries || [],
534
+ depends_on: raw.depends_on || [],
535
+ reads: raw.reads || ['.loom/PROJECT.md'],
536
+ touches: raw.touches || [],
537
+ implements: raw.implements || '',
538
+ capability_hooks: raw.capability_hooks || [],
539
+ covers: raw.covers || [],
540
+ status: 'open',
541
+ progress: raw.progress || { completed: [], current: '', next: '' },
542
+ evidence: raw.evidence || [],
543
+ created_at: now(),
544
+ updated_at: now(),
545
+ });
546
+ }
547
+ validateTasks(taskStore.tasks);
548
+ atomicJson(paths.tasks, taskStore);
549
+ return taskSummary(taskStore.tasks);
550
+ }
551
+
552
+ export function taskSummary(tasks) {
553
+ return {
554
+ total: tasks.length,
555
+ open: tasks.filter((task) => task.status === 'open').length,
556
+ active: tasks.find((task) => task.status === 'active')?.id || null,
557
+ blocked: tasks.filter((task) => task.status === 'blocked').length,
558
+ done: tasks.filter((task) => task.status === 'done').length,
559
+ };
560
+ }
561
+
562
+ export function getTask(id, root = findRoot()) {
563
+ const { taskStore } = loadProject(root);
564
+ const task = id ? taskStore.tasks.find((item) => item.id === id) : nextTask(taskStore.tasks);
565
+ if (!task) throw new Error(id ? `Task not found: ${id}` : 'No executable Task is available');
566
+ return task;
567
+ }
568
+
569
+ function nextTask(tasks) {
570
+ const active = tasks.find((task) => task.status === 'active');
571
+ if (active) return active;
572
+ const done = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.id));
573
+ return tasks.find((task) => task.status === 'open' && task.depends_on.every((id) => done.has(id))) || null;
574
+ }
575
+
576
+ export function updateTask(id, patch, root = findRoot()) {
577
+ const { paths, taskStore } = loadProject(root);
578
+ const task = taskStore.tasks.find((item) => item.id === id);
579
+ if (!task) throw new Error(`Task not found: ${id}`);
580
+ const allowed = ['title', 'outcome', 'acceptance', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'implements', 'capability_hooks', 'covers', 'progress', 'evidence'];
581
+ for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
582
+ Object.assign(task, patch, { updated_at: now() });
583
+ validateTasks(taskStore.tasks);
584
+ atomicJson(paths.tasks, taskStore);
585
+ return task;
586
+ }
587
+
588
+ export function blockTask(id, payload, root = findRoot()) {
589
+ const task = getTask(id, root);
590
+ if (task.status !== 'active') throw new Error(`${id} is not active`);
591
+ if (!payload.reason || payload.reason.length < 10) throw new Error('Blocking a Task requires a concrete reason');
592
+ if (!Array.isArray(payload.recovery_conditions) || !payload.recovery_conditions.length) throw new Error('Blocking a Task requires recovery_conditions');
593
+ const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
594
+ const { paths, taskStore } = loadProject(root);
595
+ const target = taskStore.tasks.find((item) => item.id === id);
596
+ target.status = 'blocked';
597
+ target.block = { reason: payload.reason, recovery_conditions: payload.recovery_conditions, at: now() };
598
+ target.progress = { ...target.progress, current: `blocked: ${payload.reason}`, next: payload.recovery_conditions.join('; ') };
599
+ target.evidence = [...target.evidence, ...evidence];
600
+ target.updated_at = now();
601
+ validateTasks(taskStore.tasks);
602
+ atomicJson(paths.tasks, taskStore);
603
+ return target;
604
+ }
605
+
606
+ export function reopenTask(id, options = {}, root = findRoot()) {
607
+ const { paths, taskStore } = loadProject(root);
608
+ if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
609
+ const task = taskStore.tasks.find((item) => item.id === id);
610
+ if (!task || !['blocked', 'done'].includes(task.status)) throw new Error(`${id} is neither blocked nor done`);
611
+ if (task.status === 'done' && (!options.reason || options.reason.length < 10)) throw new Error('Reopening a done Task requires a concrete --reason');
612
+ const priorStatus = task.status;
613
+ task.status = 'open';
614
+ if (priorStatus === 'done') {
615
+ task.evidence.push({ type: 'completion_reopened', reason: options.reason, at: now() });
616
+ task.progress = { ...task.progress, current: `completion reopened: ${options.reason}`, next: 'Re-run the Task and close every done condition with evidence.' };
617
+ }
618
+ task.reopened_at = now();
619
+ task.updated_at = now();
620
+ validateTasks(taskStore.tasks);
621
+ atomicJson(paths.tasks, taskStore);
622
+ const { state } = loadProject(root);
623
+ if (state.project.status === 'complete') {
624
+ state.project.status = 'building';
625
+ state.project.updated_at = now();
626
+ atomicJson(paths.state, state);
627
+ }
628
+ return task;
629
+ }
630
+
631
+ export function startTask(id, root = findRoot()) {
632
+ const { paths, state, taskStore } = loadProject(root);
633
+ if (!['passed', 'skipped'].includes(state.keeper.status)) throw new Error('The one-time Keeper handoff has not passed. Run loom keeper prompt.');
634
+ if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
635
+ const task = taskStore.tasks.find((item) => item.id === id);
636
+ if (!task || task.status !== 'open') throw new Error(`${id} is not open`);
637
+ const done = new Set(taskStore.tasks.filter((item) => item.status === 'done').map((item) => item.id));
638
+ const missing = task.depends_on.filter((dep) => !done.has(dep));
639
+ if (missing.length) throw new Error(`${id} has incomplete dependencies: ${missing.join(', ')}`);
640
+ for (const ref of task.reads) readContextDocument(paths, ref);
641
+ task.status = 'active';
642
+ task.updated_at = now();
643
+ validateTasks(taskStore.tasks);
644
+ atomicJson(paths.tasks, taskStore);
645
+ state.project.status = 'building';
646
+ state.project.updated_at = now();
647
+ atomicJson(paths.state, state);
648
+ return task;
649
+ }
650
+
651
+ export function completeTask(id, payload, root = findRoot()) {
652
+ const { paths, state, taskStore } = loadProject(root);
653
+ const task = taskStore.tasks.find((item) => item.id === id);
654
+ if (!task) throw new Error(`Task not found: ${id}`);
655
+ if (task.status !== 'active') throw new Error(`${id} is not active`);
656
+ if (!Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Completing a Task requires concrete evidence');
657
+ const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
658
+ if (hasAcceptance) {
659
+ if (!Array.isArray(payload.acceptance_results)) throw new Error('Completing a Task with acceptance[] requires acceptance_results[]');
660
+ const criteria = new Set(task.acceptance.map((acc) => acc.criterion));
661
+ const seen = new Set();
662
+ for (const result of payload.acceptance_results) {
663
+ if (!result || !criteria.has(result.criterion)) throw new Error('acceptance_results criterion must match an exact acceptance criterion');
664
+ if (seen.has(result.criterion)) throw new Error(`Duplicate acceptance result: ${result.criterion}`);
665
+ if (!result.evidence || typeof result.evidence !== 'string' || result.evidence.length < 5) throw new Error(`acceptance result requires concrete evidence: ${result.criterion}`);
666
+ seen.add(result.criterion);
667
+ }
668
+ const missingResults = task.acceptance.filter((acc) => !seen.has(acc.criterion));
669
+ if (missingResults.length) throw new Error(`Task completion is missing acceptance results:\n- ${missingResults.map((acc) => acc.criterion).join('\n- ')}`);
670
+ for (const result of payload.acceptance_results) {
671
+ const acc = task.acceptance.find((acc) => acc.criterion === result.criterion);
672
+ acc.evidence = result.evidence;
673
+ }
674
+ task.status = 'done';
675
+ task.evidence = [...task.evidence, ...payload.evidence, { type: 'acceptance_results', results: payload.acceptance_results, at: now() }];
676
+ } else {
677
+ if (!Array.isArray(payload.checks)) throw new Error('Completing a Task with done_when[] requires checks for every done_when criterion');
678
+ const criteria = new Set(task.done_when);
679
+ const seen = new Set();
680
+ for (const check of payload.checks) {
681
+ if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
682
+ if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
683
+ if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
684
+ seen.add(check.criterion);
685
+ }
686
+ const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
687
+ if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
688
+ task.status = 'done';
689
+ task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
690
+ }
691
+ task.progress = { ...task.progress, current: 'complete', next: '' };
692
+ task.updated_at = now();
693
+ validateTasks(taskStore.tasks);
694
+ atomicJson(paths.tasks, taskStore);
695
+ if (taskStore.tasks.length && taskStore.tasks.every((item) => item.status === 'done')) {
696
+ state.project.status = 'complete';
697
+ state.project.updated_at = now();
698
+ atomicJson(paths.state, state);
699
+ }
700
+ return task;
701
+ }
702
+
703
+ export function markReady(root = findRoot()) {
704
+ const { paths, state, taskStore } = loadProject(root);
705
+ const project = readFileSync(paths.project, 'utf8');
706
+ const designs = listDesigns(root);
707
+ const capabilities = listCapabilities(root);
708
+ const highOpen = state.understanding.unresolved.filter((item) => item.status === 'open' && item.impact === 'high');
709
+ const errors = [];
710
+ if (project.includes('> This is the concise entry point') || project.length < 400) errors.push('PROJECT.md still looks like a template');
711
+ if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
712
+ const unfinishedDesigns = designs.filter((name) => readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.'));
713
+ if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
714
+ const unfinishedCapabilities = capabilities.filter((name) => capabilityTemplateResidue(readFileSync(capabilityPath(paths, name), 'utf8')));
715
+ if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
716
+ if (!taskStore.tasks.length) errors.push('The initial work map is empty');
717
+ if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
718
+ const digest = projectDigest(paths, taskStore.tasks);
719
+ const latestKeeper = state.keeper.attempts.at(-1);
720
+ if (['needs_revision', 'blocked'].includes(state.keeper.status) && latestKeeper?.prepared_digest === digest) {
721
+ errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
722
+ }
723
+ if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
724
+ if (latestKeeper?.can_auto_pass && latestKeeper.prepared_digest !== digest) {
725
+ state.keeper.status = 'passed';
726
+ state.project.status = 'build_ready';
727
+ state.project.updated_at = now();
728
+ state.keeper.auto_passed = true;
729
+ atomicJson(paths.state, state);
730
+ return { ready_for_keeper: false, auto_passed: true, next: 'Keeper minor gaps fixed; auto-passed without a new Keeper round' };
731
+ }
732
+ state.project.status = 'ready_for_keeper';
733
+ state.project.updated_at = now();
734
+ state.keeper.prepared_digest = digest;
735
+ state.keeper.prepared_attempt = state.keeper.attempts.length + 1;
736
+ atomicJson(paths.state, state);
737
+ return { ready_for_keeper: true, digest: state.keeper.prepared_digest, next: 'Open a fresh Agent and run loom keeper prompt' };
738
+ }
739
+
740
+ function projectDigest(paths, tasks) {
741
+ const hash = createHash('sha256');
742
+ hash.update(readFileSync(paths.project));
743
+ hash.update(readFileSync(paths.decisions));
744
+ for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
745
+ for (const name of listCapabilities()) {
746
+ hash.update(readFileSync(capabilityPath(paths, name), 'utf8'));
747
+ const statusPath = join(paths.capabilities, name, 'status.json');
748
+ if (existsSync(statusPath)) hash.update(readFileSync(statusPath, 'utf8'));
749
+ }
750
+ hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
751
+ return hash.digest('hex');
752
+ }
753
+
754
+ export function getKeeperPrompt(root = findRoot()) {
755
+ const { state } = loadProject(root);
756
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Run loom project ready before Keeper handoff');
757
+ return keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest });
758
+ }
759
+
760
+ export function recordKeeper(payload, root = findRoot()) {
761
+ const { paths, state, taskStore } = loadProject(root);
762
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper result cannot be recorded before loom project ready');
763
+ if (!['passed', 'needs_revision', 'blocked'].includes(payload.verdict)) throw new Error('Keeper verdict must be passed, needs_revision, or blocked');
764
+ if (!payload.summary || !Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Keeper result requires summary and evidence');
765
+ if (payload.evidence.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('Keeper evidence entries must be non-empty strings');
766
+ if (payload.gaps !== undefined && !Array.isArray(payload.gaps)) throw new Error('Keeper gaps must be an array');
767
+ for (const gap of payload.gaps || []) {
768
+ if (typeof gap === 'string' && gap.trim()) continue;
769
+ if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
770
+ throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
771
+ }
772
+ const blockingGaps = (payload.gaps || []).filter((gap) => {
773
+ if (typeof gap === 'string') return true;
774
+ if (typeof gap === 'object' && gap.severity !== 'minor') return true;
775
+ return false;
776
+ });
777
+ const minorGaps = (payload.gaps || []).filter((gap) => {
778
+ if (typeof gap === 'object' && gap.severity === 'minor') return true;
779
+ return false;
780
+ });
781
+ const canAutoPass = payload.verdict === 'needs_revision' && blockingGaps.length === 0 && minorGaps.length > 0 && minorGaps.length <= 3;
782
+ if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
783
+ if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
784
+ if (!payload.prepared_digest || payload.prepared_digest !== state.keeper.prepared_digest) throw new Error('Keeper result prepared_digest does not match the current ready state');
785
+ const currentDigest = projectDigest(paths, taskStore.tasks);
786
+ if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
787
+ const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], can_auto_pass: canAutoPass, at: now() };
788
+ state.keeper.attempts.push(attempt);
789
+ state.keeper.status = payload.verdict;
790
+ if (payload.verdict === 'passed') state.project.status = 'build_ready';
791
+ else state.project.status = 'shaping';
792
+ state.project.updated_at = now();
793
+ atomicJson(paths.state, state);
794
+ return state.keeper;
795
+ }
796
+
797
+ export function skipKeeper(reason, root = findRoot()) {
798
+ if (!reason || reason.length < 10) throw new Error('Skipping Keeper requires a concrete reason');
799
+ const { paths, state } = loadProject(root);
800
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper can only be skipped after loom project ready');
801
+ state.keeper.status = 'skipped';
802
+ state.keeper.skip_reason = reason;
803
+ state.project.status = 'build_ready';
804
+ state.project.updated_at = now();
805
+ atomicJson(paths.state, state);
806
+ return state.keeper;
807
+ }
808
+
809
+ export function recordDecision(payload, root = findRoot()) {
810
+ if (!payload || !payload.summary || payload.summary.length < 10) throw new Error('Decision requires --summary (what changed and why)');
811
+ if (!payload.changes || !Array.isArray(payload.changes) || !payload.changes.length) throw new Error('Decision requires --changes (array of affected files or decisions)');
812
+ const { paths } = loadProject(root);
813
+ const id = `D-${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
814
+ const entry = `## ${id}: ${payload.summary}\n\n- Changed: ${payload.changes.join(', ')}\n${payload.affected_tasks ? `- Affected tasks: ${payload.affected_tasks.join(', ')}\n` : ''}- At: ${now()}\n`;
815
+ const existing = readFileSync(paths.decisions, 'utf8');
816
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
817
+ writeFileSync(paths.decisions, existing + separator + entry, 'utf8');
818
+ return { id, summary: payload.summary, changes: payload.changes, affected_tasks: payload.affected_tasks || [] };
819
+ }
820
+
821
+ export function compileContext(options = {}, root = findRoot()) {
822
+ const { paths, state, taskStore } = loadProject(root);
823
+ const designs = listDesigns(root);
824
+ const capabilities = listCapabilities(root);
825
+ const summary = taskSummary(taskStore.tasks);
826
+ const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
827
+ const next = nextTask(taskStore.tasks);
828
+ const recommendation = task
829
+ ? `You have an active Task: ${task.id}. Read the Active Task, its reads, and the Capability decision points below. Then take the smallest action that advances the outcome inside the boundaries. Update progress or mark done only with concrete evidence.`
830
+ : next
831
+ ? `No active Task. The next executable Task is ${next.id}. Start it with \`loom task start ${next.id}\` if the project is build_ready, or run \`loom project ready\` if not. If the next Task is wrong, repair the Work Map first.`
832
+ : state.project.status === 'complete'
833
+ ? 'All Tasks are done. Run \`loom check\` to verify health. If new work arises, record the decision and update the Work Map.'
834
+ : state.project.status === 'shaping'
835
+ ? 'Project is still shaping. Confirm the intended result, identify open questions, and build the Work Map before starting material work.'
836
+ : 'No executable Task. Create or update Tasks so the Work Map matches the project goal.';
837
+ const statusBlock = `## Current LOOM state and recommended action\n\n- Project status: ${state.project.status}\n- Active task: ${summary.active || 'none'}\n- Work map: ${summary.total} total, ${summary.open} open, ${summary.done} done, ${summary.blocked} blocked\n- Design documents: ${designs.length}\n- Capability dossiers: ${capabilities.length}\n- Keeper status: ${state.keeper.status}\n\n**Recommended next action:** ${recommendation}\n\nThis is a recommendation, not a script. Use your judgment; if you choose differently, record the reason in \`.loom/DECISIONS.md\` or the active Task evidence.`;
838
+ const blocks = [statusBlock, agentProtocol({ humanChannel: options.humanChannel || 'available' }), shapingContext({ state, taskSummary: summary, capabilityNames: capabilities, designNames: designs, forKeeper: Boolean(options.keeper) })];
839
+ blocks.push(`## Project whole (${normalizeRef(paths, paths.project)})\n\n${readFileSync(paths.project, 'utf8')}`);
840
+ if (existsSync(paths.structure)) blocks.push(`## Project structure (${normalizeRef(paths, paths.structure)})\n\n${readFileSync(paths.structure, 'utf8')}`);
841
+ if (options.keeper) {
842
+ blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
843
+ blocks.push(`## Decision history (${normalizeRef(paths, paths.decisions)})\n\n${readFileSync(paths.decisions, 'utf8')}`);
844
+ blocks.push(`## Work map summary\n\n${JSON.stringify(summary, null, 2)}\n\nFirst executable Task:\n\n${JSON.stringify(nextTask(taskStore.tasks), null, 2)}`);
845
+ for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
846
+ for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
847
+ } else if (task) {
848
+ blocks.push(EXECUTION_PROTOCOL);
849
+ blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
850
+ for (const ref of task.reads) {
851
+ const content = readContextDocument(paths, ref);
852
+ if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
853
+ }
854
+ if (task.capability_hooks && task.capability_hooks.length) {
855
+ const hookBlocks = [];
856
+ for (const hook of task.capability_hooks) {
857
+ const nodeContent = extractCapabilityNode(paths, hook.node);
858
+ if (nodeContent) {
859
+ const atLine = hook.at ? `\n\n**Activate at:** ${hook.at}` : '';
860
+ const produceLine = hook.must_produce ? `\n\n**Must produce:** ${hook.must_produce}` : '';
861
+ hookBlocks.push(`### Capability hook: ${hook.node}${atLine}${produceLine}\n\n${nodeContent}`);
862
+ }
863
+ }
864
+ if (hookBlocks.length) blocks.push(`## Capability decision points\n\nYou are at specific decision-tree nodes from professional capability dossiers. Use them to inform your judgment — each node carries options, criteria, a source, and a counterexample. If the evidence points somewhere the tree does not cover, trust the evidence and update the capability.\n\n${hookBlocks.join('\n\n')}`);
865
+ }
866
+ } else {
867
+ blocks.push(`## On-demand project context\n\n- Decision history: .loom/DECISIONS.md (read when correction or lineage matters)\n${designs.length ? designs.map((name) => `- Design: .loom/design/${name}`).join('\n') : '- No design documents yet; split the whole according to consequential systems and decisions.'}\n${capabilities.length ? capabilities.map((name) => `- Professional capability: .loom/capabilities/${name}`).join('\n') : '- No professional capability dossiers yet; create separate field dossiers only where expertise changes the work.'}`);
868
+ }
869
+ return blocks.filter(Boolean).join('\n\n---\n\n');
870
+ }
871
+
872
+ function normalizeRef(paths, absolute) {
873
+ const loomPrefix = `${paths.loom}${process.platform === 'win32' ? '\\' : '/'}`;
874
+ if (absolute === paths.loom || absolute.startsWith(loomPrefix)) return `.loom/${relative(paths.loom, absolute).replaceAll('\\', '/')}`.replace(/\/$/, '');
875
+ return relative(paths.root, absolute).replaceAll('\\', '/');
876
+ }
877
+
878
+ function readContextDocument(paths, ref) {
879
+ const normalized = ref.replaceAll('\\', '/');
880
+ if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
881
+ const fromLoom = normalized === '.loom' || normalized.startsWith('.loom/');
882
+ const base = fromLoom ? paths.loom : paths.root;
883
+ const relativeRef = fromLoom ? normalized.slice('.loom'.length).replace(/^\//, '') : normalized;
884
+ const absolute = resolve(base, relativeRef);
885
+ const prefix = `${base}${process.platform === 'win32' ? '\\' : '/'}`;
886
+ if (!absolute.startsWith(prefix) && absolute !== base) throw new Error(`Unsafe context reference: ${ref}`);
887
+ if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
888
+ if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
889
+ return readFileSync(absolute, 'utf8');
890
+ }
891
+
892
+ export function checkProject(root = findRoot()) {
893
+ const { paths, state, taskStore } = loadProject(root);
894
+ const errors = [];
895
+ const warnings = [];
896
+ for (const task of taskStore.tasks) {
897
+ for (const ref of task.reads) {
898
+ try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
899
+ }
900
+ if (task.capability_hooks) {
901
+ for (const hook of task.capability_hooks) {
902
+ const nodeContent = extractCapabilityNode(paths, hook.node);
903
+ if (nodeContent === null) warnings.push(`${task.id} references missing capability node: ${hook.node}`);
904
+ }
905
+ }
906
+ const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
907
+ const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
908
+ if (!hasAcceptance && hasDoneWhen) warnings.push(`${task.id} uses done_when[] without acceptance[] — consider migrating to structured acceptance for clearer verification`);
909
+ }
910
+ if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
911
+ if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
912
+ const decisionsContent = readFileSync(paths.decisions, 'utf8');
913
+ const affectedMatches = [...decisionsContent.matchAll(/Affected tasks: (.+)/g)];
914
+ const allAffected = new Set();
915
+ for (const match of affectedMatches) {
916
+ for (const taskId of match[1].split(',').map((s) => s.trim()).filter(Boolean)) allAffected.add(taskId);
917
+ }
918
+ for (const taskId of allAffected) {
919
+ const task = taskStore.tasks.find((item) => item.id === taskId);
920
+ if (task && task.status === 'done') warnings.push(`${taskId} is done but was marked affected by a decision; consider reopening if the change invalidates prior work`);
921
+ }
922
+ if (!listCapabilities(root).length) warnings.push('No capability dossier exists; acceptable only when specialist judgment would not change the work');
923
+ if (!listDesigns(root).length) warnings.push('No design document exists; PROJECT.md should remain a concise map of the whole');
924
+ if (!existsSync(paths.structure)) warnings.push('No STRUCTURE.md exists; declare where files go so the Agent does not guess');
925
+ else if (readFileSync(paths.structure, 'utf8').includes('Where implementation files go. Example:')) warnings.push('STRUCTURE.md still contains template instructions; customize it for this project');
926
+ for (const name of listDesigns(root)) {
927
+ if (readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.')) warnings.push(`Design document still contains template instructions: ${name}`);
928
+ }
929
+ for (const name of listCapabilities(root)) {
930
+ const content = readFileSync(capabilityPath(paths, name), 'utf8');
931
+ if (capabilityTemplateResidue(content)) warnings.push(`Capability dossier still contains template instructions: ${name}`);
932
+ if (content.includes('### C') && !content.includes('source:')) warnings.push(`Capability dossier has decision tree nodes without source citations: ${name}`);
933
+ const statusPath = join(paths.capabilities, name, 'status.json');
934
+ if (existsSync(statusPath)) {
935
+ const capStatus = readJson(statusPath, 'status.json');
936
+ if (capStatus.status && capStatus.status !== 'confirmed') warnings.push(`Capability ${name} is ${capStatus.status}, not confirmed; tasks referencing it proceed provisionally`);
937
+ }
938
+ }
939
+ const coverage = checkDeliverableCoverage(root);
940
+ if (coverage.uncovered > 0) warnings.push(`Uncovered deliverables: ${coverage.uncovered_items.map((item) => item.slug).join(', ')}`);
941
+ return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks), deliverable_coverage: { total: coverage.total, covered: coverage.covered, uncovered: coverage.uncovered } };
942
+ }
943
+
944
+ export function scaffoldEval(payload, root = findRoot()) {
945
+ if (!payload.id || !payload.title || !payload.brief) throw new Error('Eval scenario requires id, title, and brief');
946
+ const { paths } = loadProject(root);
947
+ const slug = payload.id.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
948
+ const dir = join(paths.eval, slug);
949
+ if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
950
+ mkdirSync(dir, { recursive: true });
951
+ const humanChannel = payload.human_channel || 'available';
952
+ if (!['available', 'unavailable'].includes(humanChannel)) throw new Error('human_channel must be available or unavailable');
953
+ const manifest = {
954
+ schema_version: 1,
955
+ id: payload.id,
956
+ title: payload.title,
957
+ brief: payload.brief,
958
+ primary_comparison: 'same capable Agent without LOOM vs with LOOM',
959
+ hidden_user_facts: payload.hidden_user_facts || [],
960
+ success_criteria: payload.success_criteria || [],
961
+ conditions: [
962
+ { id: 'baseline', framework: 'none', instruction: 'Work normally with all ordinary Agent capabilities and tools.' },
963
+ { id: 'loom', framework: 'loom-v2', instruction: 'Use LOOM as invisible Agent continuity infrastructure.' },
964
+ ],
965
+ controls: {
966
+ same_model_tools_workspace_and_budget: true,
967
+ human_channel: humanChannel,
968
+ minimum_repetitions_per_condition: payload.repetitions || 3,
969
+ scripted_user_answers: true,
970
+ context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
971
+ blind_pairwise_order_swap: true,
972
+ anonymization_preserves_relative_layout: true,
973
+ judge_packet_preflight_required: true,
974
+ condition_output_digest_manifest: true,
975
+ },
976
+ measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
977
+ };
978
+ atomicJson(join(dir, 'manifest.json'), manifest);
979
+ writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false, humanChannel })}\n`, 'utf8');
980
+ writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true, humanChannel })}\n`, 'utf8');
981
+ writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
982
+ return { scenario: payload.id, path: normalizeRef(paths, dir), controls: manifest.controls };
983
+ }