@haaaiawd/loom 2.1.2 → 2.1.3

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,598 +1,636 @@
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
- }
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
199
  if (task.implements !== undefined && typeof task.implements !== 'string') throw new Error(`${task.id} implements must be a string referencing a design decision`);
200
200
  if (task.design_exemption !== undefined && (typeof task.design_exemption !== 'string' || (task.design_exemption && task.design_exemption.length < 10))) throw new Error(`${task.id} design_exemption must be a concrete reason`);
201
201
  if (task.capability_exemption !== undefined && (typeof task.capability_exemption !== 'string' || (task.capability_exemption && task.capability_exemption.length < 10))) throw new Error(`${task.id} capability_exemption must be a concrete reason`);
202
202
  if (task.integrity_version !== undefined && task.integrity_version !== 1) throw new Error(`${task.id} integrity_version is unsupported`);
203
- if (task.capability_hooks !== undefined) {
204
- if (!Array.isArray(task.capability_hooks)) throw new Error(`${task.id} capability_hooks must be an array`);
205
- for (const hook of task.capability_hooks) {
206
- 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"`);
207
- if (hook.at !== undefined && typeof hook.at !== 'string') throw new Error(`${task.id} capability_hooks at must be a string`);
208
- if (hook.must_produce !== undefined && typeof hook.must_produce !== 'string') throw new Error(`${task.id} capability_hooks must_produce must be a string`);
209
- }
210
- }
211
- if (task.covers !== undefined) {
212
- if (!Array.isArray(task.covers)) throw new Error(`${task.id} covers must be an array of deliverable IDs`);
213
- for (const dlvId of task.covers) if (typeof dlvId !== 'string') throw new Error(`${task.id} covers entries must be deliverable ID strings`);
214
- }
215
- if (task.status === 'active') active += 1;
216
- }
217
- if (active > 1) throw new Error('Only one Task may be active');
218
- for (const task of tasks) {
219
- for (const dep of task.depends_on) if (!ids.has(dep)) throw new Error(`${task.id} depends on missing Task ${dep}`);
220
- }
221
- detectCycles(tasks);
222
- }
223
-
224
- function detectCycles(tasks) {
225
- const map = new Map(tasks.map((task) => [task.id, task.depends_on]));
226
- const visiting = new Set();
227
- const visited = new Set();
228
- function visit(id) {
229
- if (visiting.has(id)) throw new Error(`Task dependency cycle includes ${id}`);
230
- if (visited.has(id)) return;
231
- visiting.add(id);
232
- for (const dep of map.get(id) || []) visit(dep);
233
- visiting.delete(id);
234
- visited.add(id);
235
- }
236
- for (const id of map.keys()) visit(id);
237
- }
238
-
239
- export function recordUnderstanding(payload, root = findRoot()) {
240
- const { paths, state } = loadProject(root);
241
- for (const item of asObjects(payload.confirmed, 'text')) {
242
- if (!item.text) throw new Error('Confirmed fact requires text');
243
- state.understanding.confirmed.push({ id: nextId(state.understanding.confirmed, 'F'), text: item.text, source: item.source || 'conversation', at: now() });
244
- }
245
- for (const item of asObjects(payload.assumptions, 'text')) {
246
- if (!item.text) throw new Error('Assumption requires text');
247
- state.understanding.assumptions.push({ id: nextId(state.understanding.assumptions, 'A'), text: item.text, status: 'active', source: item.source || 'agent', at: now() });
248
- }
249
- for (const item of asObjects(payload.unresolved, 'question')) {
250
- if (!item.question) throw new Error('Unresolved item requires question');
251
- state.understanding.unresolved.push({ id: nextId(state.understanding.unresolved, 'Q'), question: item.question, impact: item.impact || 'medium', status: 'open', at: now() });
252
- }
253
- for (const item of payload.resolved || []) {
254
- const target = state.understanding.unresolved.find((candidate) => candidate.id === item.id);
255
- if (!target) throw new Error(`Unknown unresolved id ${item.id}`);
256
- target.status = item.status === 'skipped' ? 'skipped' : 'resolved';
257
- target.resolution = item.resolution || '';
258
- target.resolved_at = now();
259
- }
260
- for (const id of payload.retire_assumptions || []) {
261
- const target = state.understanding.assumptions.find((candidate) => candidate.id === id);
262
- if (!target) throw new Error(`Unknown assumption id ${id}`);
263
- target.status = 'retired';
264
- }
265
- if (payload.project_status) throw new Error('Project status is controlled by ready, Keeper, and Task commands');
266
- for (const decision of payload.decisions || []) appendDecision(paths.decisions, decision, state);
267
- state.project.updated_at = now();
268
- atomicJson(paths.state, state);
269
- return state.understanding;
270
- }
271
-
272
- function appendDecision(path, decision, state) {
273
- if (!decision.title || !decision.decision || !decision.rationale) throw new Error('Decision requires title, decision, and rationale');
274
- const prior = state.decision_ids || [];
275
- const id = nextId(prior.map((value) => ({ id: value })), 'D');
276
- state.decision_ids = [...prior, id];
277
- const supersedes = decision.supersedes?.length ? decision.supersedes.join(', ') : 'none';
278
- 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');
279
- }
280
-
281
- export function createDesign(slug, options = {}, root = findRoot()) {
282
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Design slug must use lowercase letters, numbers, and hyphens');
283
- if (!options.title) throw new Error('Design document requires --title');
284
- if (!DESIGN_KINDS.includes(options.kind)) throw new Error(`Design kind must be one of: ${DESIGN_KINDS.join(', ')}`);
285
- const { paths } = loadProject(root);
286
- const path = join(paths.design, `${slug}.md`);
287
- if (existsSync(path)) throw new Error(`Design document already exists: ${slug}`);
288
- writeFileSync(path, designTemplate({ title: options.title, kind: options.kind }), 'utf8');
289
- return { slug, kind: options.kind, path: relative(paths.root, path).replaceAll('\\', '/') };
290
- }
291
-
292
- export function listDesigns(root = findRoot()) {
293
- const { paths } = loadProject(root);
294
- return readdirSync(paths.design).filter((name) => name.endsWith('.md')).sort();
295
- }
296
-
297
- export function getDesign(slug, root = findRoot()) {
298
- const { paths } = loadProject(root);
299
- const name = slug.endsWith('.md') ? slug : `${slug}.md`;
300
- if (basename(name) !== name) throw new Error('Invalid design document name');
301
- const path = join(paths.design, name);
302
- if (!existsSync(path)) throw new Error(`Design document not found: ${slug}`);
303
- return readFileSync(path, 'utf8');
304
- }
305
-
306
- export function createCapability(slug, options = {}, root = findRoot()) {
307
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
308
- if (!options.title) throw new Error('Capability requires --title');
309
- const { paths } = loadProject(root);
310
- const dirPath = join(paths.capabilities, slug);
311
- const legacyPath = join(paths.capabilities, `${slug}.md`);
312
- if (existsSync(dirPath) || existsSync(legacyPath)) throw new Error(`Capability already exists: ${slug}`);
313
- mkdirSync(join(dirPath, 'research'), { recursive: true });
314
- const capabilityPath = join(dirPath, 'capability.md');
315
- writeFileSync(capabilityPath, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
316
- atomicJson(join(dirPath, 'status.json'), { status: 'researching', title: options.title, field: options.field || '', scenario: '', confirmed_at: '', created_at: now() });
317
- return { slug, path: relative(paths.root, capabilityPath).replaceAll('\\', '/') };
318
- }
319
-
320
- export function listCapabilities(root = findRoot()) {
321
- const { paths } = loadProject(root);
322
- const entries = readdirSync(paths.capabilities, { withFileTypes: true });
323
- const names = entries
324
- .filter((entry) => (entry.isDirectory() && existsSync(join(paths.capabilities, entry.name, 'capability.md'))) || (entry.isFile() && entry.name.endsWith('.md')))
325
- .map((entry) => (entry.isDirectory() ? entry.name : entry.name))
326
- .sort();
327
- return names;
328
- }
329
-
330
- export function getCapability(slug, root = findRoot()) {
331
- const { paths } = loadProject(root);
332
- const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug;
333
- if (basename(cleanSlug) !== cleanSlug) throw new Error('Invalid capability name');
334
- const dirPath = join(paths.capabilities, cleanSlug);
335
- const dirCapability = join(dirPath, 'capability.md');
336
- if (existsSync(dirCapability)) return readFileSync(dirCapability, 'utf8');
337
- const legacyPath = join(paths.capabilities, `${cleanSlug}.md`);
338
- if (existsSync(legacyPath)) return readFileSync(legacyPath, 'utf8');
339
- throw new Error(`Capability not found: ${slug}`);
340
- }
341
-
342
- export function researchCapability(slug, options = {}, root = findRoot()) {
343
- if (!options.field) throw new Error('Research requires --field <professional-field>');
344
- const { paths } = loadProject(root);
345
- const dir = join(paths.capabilities, slug);
346
- if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}. Run loom capability add ${slug} --title <text> first.`);
347
- const statusPath = join(dir, 'status.json');
348
- if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
349
- const status = readJson(statusPath, 'status.json');
350
- if (status.status === 'confirmed') {
351
- status.reopened_at = now();
352
- status.reopen_reason = options.reopen_reason || 'new evidence requires updating the capability';
353
- }
354
- status.field = options.field;
355
- status.status = 'researching';
356
- status.updated_at = now();
357
- atomicJson(statusPath, status);
358
- const researchDir = join(dir, 'research');
359
- mkdirSync(researchDir, { recursive: true });
360
- const guidePath = join(researchDir, '_guide.md');
361
- if (!existsSync(guidePath)) writeFileSync(guidePath, RESEARCH_GUIDE, 'utf8');
362
- return {
363
- slug,
364
- field: options.field,
365
- research_dir: relative(paths.root, researchDir).replaceAll('\\', '/'),
366
- status: 'researching',
367
- 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}.`,
368
- };
369
- }
370
-
371
- export function synthesizeCapability(slug, root = findRoot()) {
372
- const { paths } = loadProject(root);
373
- const dir = join(paths.capabilities, slug);
374
- if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
375
- const statusPath = join(dir, 'status.json');
376
- if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
377
- const status = readJson(statusPath, 'status.json');
378
- if (status.status === 'confirmed') throw new Error(`Capability ${slug} is confirmed; run loom capability research ${slug} --field <text> to reopen it with new evidence`);
379
- const researchDir = join(dir, 'research');
380
- const materials = readdirSync(researchDir).filter((f) => f.endsWith('.md') && f !== '_guide.md').sort();
381
- 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}.`);
382
- const capabilityPath = join(dir, 'capability.md');
383
- const content = readFileSync(capabilityPath, 'utf8');
384
- const nodePattern = /### (C\d+):/g;
385
- const nodes = [...content.matchAll(nodePattern)].map((m) => m[1]);
386
- if (!nodes.length) throw new Error('Capability has no decision tree nodes (### C1, C2, ...). Add nodes before synthesizing.');
387
- const missingSources = [];
388
- const missingCounterexamples = [];
389
- const danglingSources = [];
390
- const materialNames = materials.flatMap((f) => [f, f.slice(0, -3)]);
391
- for (const node of nodes) {
392
- const nodeSection = content.split(`### ${node}:`)[1]?.split('### ')[0] || '';
393
- if (!nodeSection.includes('source:')) missingSources.push(node);
394
- else {
395
- const sourceMatch = nodeSection.match(/source:\s*(.+)(?:\n|$)/);
396
- const cited = sourceMatch ? sourceMatch[1].trim() : '';
397
- if (cited && !materialNames.some((name) => cited.includes(name))) danglingSources.push(node);
398
- }
399
- if (!nodeSection.includes('counterexample:')) missingCounterexamples.push(node);
400
- }
401
- if (missingSources.length) throw new Error(`Decision tree nodes missing source citations: ${missingSources.join(', ')}. Every node must reference a research material.`);
402
- 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.`);
403
- 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).`);
404
- status.status = 'synthesized';
405
- status.updated_at = now();
406
- atomicJson(statusPath, status);
407
- return { slug, status: 'synthesized', nodes: nodes.length, materials: materials.length };
408
- }
409
-
203
+ if (task.capability_hooks !== undefined) {
204
+ if (!Array.isArray(task.capability_hooks)) throw new Error(`${task.id} capability_hooks must be an array`);
205
+ for (const hook of task.capability_hooks) {
206
+ 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"`);
207
+ if (hook.at !== undefined && typeof hook.at !== 'string') throw new Error(`${task.id} capability_hooks at must be a string`);
208
+ if (hook.must_produce !== undefined && typeof hook.must_produce !== 'string') throw new Error(`${task.id} capability_hooks must_produce must be a string`);
209
+ }
210
+ }
211
+ if (task.covers !== undefined) {
212
+ if (!Array.isArray(task.covers)) throw new Error(`${task.id} covers must be an array of deliverable IDs`);
213
+ for (const dlvId of task.covers) if (typeof dlvId !== 'string') throw new Error(`${task.id} covers entries must be deliverable ID strings`);
214
+ }
215
+ if (task.status === 'active') active += 1;
216
+ }
217
+ if (active > 1) throw new Error('Only one Task may be active');
218
+ for (const task of tasks) {
219
+ for (const dep of task.depends_on) if (!ids.has(dep)) throw new Error(`${task.id} depends on missing Task ${dep}`);
220
+ }
221
+ detectCycles(tasks);
222
+ }
223
+
224
+ function detectCycles(tasks) {
225
+ const map = new Map(tasks.map((task) => [task.id, task.depends_on]));
226
+ const visiting = new Set();
227
+ const visited = new Set();
228
+ function visit(id) {
229
+ if (visiting.has(id)) throw new Error(`Task dependency cycle includes ${id}`);
230
+ if (visited.has(id)) return;
231
+ visiting.add(id);
232
+ for (const dep of map.get(id) || []) visit(dep);
233
+ visiting.delete(id);
234
+ visited.add(id);
235
+ }
236
+ for (const id of map.keys()) visit(id);
237
+ }
238
+
239
+ export function recordUnderstanding(payload, root = findRoot()) {
240
+ const { paths, state } = loadProject(root);
241
+ for (const item of asObjects(payload.confirmed, 'text')) {
242
+ if (!item.text) throw new Error('Confirmed fact requires text');
243
+ state.understanding.confirmed.push({ id: nextId(state.understanding.confirmed, 'F'), text: item.text, source: item.source || 'conversation', at: now() });
244
+ }
245
+ for (const item of asObjects(payload.assumptions, 'text')) {
246
+ if (!item.text) throw new Error('Assumption requires text');
247
+ state.understanding.assumptions.push({ id: nextId(state.understanding.assumptions, 'A'), text: item.text, status: 'active', source: item.source || 'agent', at: now() });
248
+ }
249
+ for (const item of asObjects(payload.unresolved, 'question')) {
250
+ if (!item.question) throw new Error('Unresolved item requires question');
251
+ state.understanding.unresolved.push({ id: nextId(state.understanding.unresolved, 'Q'), question: item.question, impact: item.impact || 'medium', status: 'open', at: now() });
252
+ }
253
+ for (const item of payload.resolved || []) {
254
+ const target = state.understanding.unresolved.find((candidate) => candidate.id === item.id);
255
+ if (!target) throw new Error(`Unknown unresolved id ${item.id}`);
256
+ target.status = item.status === 'skipped' ? 'skipped' : 'resolved';
257
+ target.resolution = item.resolution || '';
258
+ target.resolved_at = now();
259
+ }
260
+ for (const id of payload.retire_assumptions || []) {
261
+ const target = state.understanding.assumptions.find((candidate) => candidate.id === id);
262
+ if (!target) throw new Error(`Unknown assumption id ${id}`);
263
+ target.status = 'retired';
264
+ }
265
+ if (payload.project_status) throw new Error('Project status is controlled by ready, Keeper, and Task commands');
266
+ for (const decision of payload.decisions || []) appendDecision(paths.decisions, decision, state);
267
+ state.project.updated_at = now();
268
+ atomicJson(paths.state, state);
269
+ return state.understanding;
270
+ }
271
+
272
+ function appendDecision(path, decision, state) {
273
+ if (!decision.title || !decision.decision || !decision.rationale) throw new Error('Decision requires title, decision, and rationale');
274
+ const prior = state.decision_ids || [];
275
+ const id = nextId(prior.map((value) => ({ id: value })), 'D');
276
+ state.decision_ids = [...prior, id];
277
+ const supersedes = decision.supersedes?.length ? decision.supersedes.join(', ') : 'none';
278
+ 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');
279
+ }
280
+
281
+ export function createDesign(slug, options = {}, root = findRoot()) {
282
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Design slug must use lowercase letters, numbers, and hyphens');
283
+ if (!options.title) throw new Error('Design document requires --title');
284
+ if (!DESIGN_KINDS.includes(options.kind)) throw new Error(`Design kind must be one of: ${DESIGN_KINDS.join(', ')}`);
285
+ const { paths } = loadProject(root);
286
+ const path = join(paths.design, `${slug}.md`);
287
+ if (existsSync(path)) throw new Error(`Design document already exists: ${slug}`);
288
+ writeFileSync(path, designTemplate({ title: options.title, kind: options.kind }), 'utf8');
289
+ return { slug, kind: options.kind, path: relative(paths.root, path).replaceAll('\\', '/') };
290
+ }
291
+
292
+ export function listDesigns(root = findRoot()) {
293
+ const { paths } = loadProject(root);
294
+ return readdirSync(paths.design).filter((name) => name.endsWith('.md')).sort();
295
+ }
296
+
297
+ export function getDesign(slug, root = findRoot()) {
298
+ const { paths } = loadProject(root);
299
+ const name = slug.endsWith('.md') ? slug : `${slug}.md`;
300
+ if (basename(name) !== name) throw new Error('Invalid design document name');
301
+ const path = join(paths.design, name);
302
+ if (!existsSync(path)) throw new Error(`Design document not found: ${slug}`);
303
+ return readFileSync(path, 'utf8');
304
+ }
305
+
306
+ export function createCapability(slug, options = {}, root = findRoot()) {
307
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
308
+ if (!options.title) throw new Error('Capability requires --title');
309
+ const { paths } = loadProject(root);
310
+ const dirPath = join(paths.capabilities, slug);
311
+ const legacyPath = join(paths.capabilities, `${slug}.md`);
312
+ if (existsSync(dirPath) || existsSync(legacyPath)) throw new Error(`Capability already exists: ${slug}`);
313
+ mkdirSync(join(dirPath, 'research'), { recursive: true });
314
+ const capabilityPath = join(dirPath, 'capability.md');
315
+ writeFileSync(capabilityPath, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
316
+ atomicJson(join(dirPath, 'status.json'), { status: 'researching', title: options.title, field: options.field || '', scenario: '', confirmed_at: '', created_at: now() });
317
+ return { slug, path: relative(paths.root, capabilityPath).replaceAll('\\', '/') };
318
+ }
319
+
320
+ export function listCapabilities(root = findRoot()) {
321
+ const { paths } = loadProject(root);
322
+ const entries = readdirSync(paths.capabilities, { withFileTypes: true });
323
+ const names = entries
324
+ .filter((entry) => (entry.isDirectory() && existsSync(join(paths.capabilities, entry.name, 'capability.md'))) || (entry.isFile() && entry.name.endsWith('.md')))
325
+ .map((entry) => (entry.isDirectory() ? entry.name : entry.name))
326
+ .sort();
327
+ return names;
328
+ }
329
+
330
+ export function getCapability(slug, root = findRoot()) {
331
+ const { paths } = loadProject(root);
332
+ const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug;
333
+ if (basename(cleanSlug) !== cleanSlug) throw new Error('Invalid capability name');
334
+ const dirPath = join(paths.capabilities, cleanSlug);
335
+ const dirCapability = join(dirPath, 'capability.md');
336
+ if (existsSync(dirCapability)) return readFileSync(dirCapability, 'utf8');
337
+ const legacyPath = join(paths.capabilities, `${cleanSlug}.md`);
338
+ if (existsSync(legacyPath)) return readFileSync(legacyPath, 'utf8');
339
+ throw new Error(`Capability not found: ${slug}`);
340
+ }
341
+
342
+ export function researchCapability(slug, options = {}, root = findRoot()) {
343
+ if (!options.field) throw new Error('Research requires --field <professional-field>');
344
+ const { paths } = loadProject(root);
345
+ const dir = join(paths.capabilities, slug);
346
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}. Run loom capability add ${slug} --title <text> first.`);
347
+ const statusPath = join(dir, 'status.json');
348
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
349
+ const status = readJson(statusPath, 'status.json');
350
+ if (status.status === 'confirmed') {
351
+ status.reopened_at = now();
352
+ status.reopen_reason = options.reopen_reason || 'new evidence requires updating the capability';
353
+ }
354
+ status.field = options.field;
355
+ status.status = 'researching';
356
+ status.updated_at = now();
357
+ atomicJson(statusPath, status);
358
+ const researchDir = join(dir, 'research');
359
+ mkdirSync(researchDir, { recursive: true });
360
+ const guidePath = join(researchDir, '_guide.md');
361
+ if (!existsSync(guidePath)) writeFileSync(guidePath, RESEARCH_GUIDE, 'utf8');
362
+ return {
363
+ slug,
364
+ field: options.field,
365
+ research_dir: relative(paths.root, researchDir).replaceAll('\\', '/'),
366
+ status: 'researching',
367
+ 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}.`,
368
+ };
369
+ }
370
+
371
+ export function synthesizeCapability(slug, root = findRoot()) {
372
+ const { paths } = loadProject(root);
373
+ const dir = join(paths.capabilities, slug);
374
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
375
+ const statusPath = join(dir, 'status.json');
376
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
377
+ const status = readJson(statusPath, 'status.json');
378
+ if (status.status === 'confirmed') throw new Error(`Capability ${slug} is confirmed; run loom capability research ${slug} --field <text> to reopen it with new evidence`);
379
+ const researchDir = join(dir, 'research');
380
+ const materials = readdirSync(researchDir).filter((f) => f.endsWith('.md') && f !== '_guide.md').sort();
381
+ 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}.`);
382
+ const capabilityPath = join(dir, 'capability.md');
383
+ const content = readFileSync(capabilityPath, 'utf8');
384
+ const nodePattern = /### (C\d+):/g;
385
+ const nodes = [...content.matchAll(nodePattern)].map((m) => m[1]);
386
+ if (!nodes.length) throw new Error('Capability has no decision tree nodes (### C1, C2, ...). Add nodes before synthesizing.');
387
+ const missingSources = [];
388
+ const missingCounterexamples = [];
389
+ const danglingSources = [];
390
+ const materialNames = materials.flatMap((f) => [f, f.slice(0, -3)]);
391
+ for (const node of nodes) {
392
+ const nodeSection = content.split(`### ${node}:`)[1]?.split('### ')[0] || '';
393
+ if (!nodeSection.includes('source:')) missingSources.push(node);
394
+ else {
395
+ const sourceMatch = nodeSection.match(/source:\s*(.+)(?:\n|$)/);
396
+ const cited = sourceMatch ? sourceMatch[1].trim() : '';
397
+ if (cited && !materialNames.some((name) => cited.includes(name))) danglingSources.push(node);
398
+ }
399
+ if (!nodeSection.includes('counterexample:')) missingCounterexamples.push(node);
400
+ }
401
+ if (missingSources.length) throw new Error(`Decision tree nodes missing source citations: ${missingSources.join(', ')}. Every node must reference a research material.`);
402
+ 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.`);
403
+ 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).`);
404
+ status.status = 'synthesized';
405
+ status.updated_at = now();
406
+ atomicJson(statusPath, status);
407
+ return { slug, status: 'synthesized', nodes: nodes.length, materials: materials.length };
408
+ }
409
+
410
410
  export function confirmCapability(slug, options = {}, root = findRoot()) {
411
411
  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)');
412
412
  if (!['human', 'agent'].includes(options.source)) throw new Error('Capability confirmation requires explicit provenance: --source human|agent');
413
- const { paths } = loadProject(root);
414
- const dir = join(paths.capabilities, slug);
415
- if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
416
- const statusPath = join(dir, 'status.json');
417
- if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
418
- const status = readJson(statusPath, 'status.json');
413
+ const { paths } = loadProject(root);
414
+ const dir = join(paths.capabilities, slug);
415
+ if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
416
+ const statusPath = join(dir, 'status.json');
417
+ if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
418
+ const status = readJson(statusPath, 'status.json');
419
419
  if (!['synthesized', 'provisional'].includes(status.status)) throw new Error(`Capability ${slug} must be synthesized before confirmation. Run loom capability synthesize ${slug} first.`);
420
420
  status.scenario = options.scenario;
421
421
  status.source = options.source;
422
422
  status.status = options.source === 'human' ? 'confirmed' : 'provisional';
423
423
  status.selected_at = now();
424
424
  status.confirmed_at = options.source === 'human' ? now() : '';
425
- status.updated_at = now();
426
- atomicJson(statusPath, status);
427
- const capabilityPath = join(dir, 'capability.md');
428
- const content = readFileSync(capabilityPath, 'utf8');
429
- const scenarioMatch = content.match(/(## Project scenario\n)([\s\S]*?)(\n## )/);
430
- if (scenarioMatch) {
431
- const prefix = scenarioMatch[1];
432
- const suffix = scenarioMatch[3];
433
- const blockquote = scenarioMatch[2].match(/(> [^\n]+\n)+/);
434
- const blockquoteText = blockquote ? blockquote[0] : '';
435
- const updated = content.replace(/## Project scenario\n[\s\S]*?\n## /, `${prefix}${blockquoteText}\n${options.scenario}\n${suffix}`);
436
- writeFileSync(capabilityPath, updated, 'utf8');
437
- }
425
+ status.updated_at = now();
426
+ atomicJson(statusPath, status);
427
+ const capabilityPath = join(dir, 'capability.md');
428
+ const content = readFileSync(capabilityPath, 'utf8');
429
+ const scenarioMatch = content.match(/(## Project scenario\n)([\s\S]*?)(\n## )/);
430
+ if (scenarioMatch) {
431
+ const prefix = scenarioMatch[1];
432
+ const suffix = scenarioMatch[3];
433
+ const blockquote = scenarioMatch[2].match(/(> [^\n]+\n)+/);
434
+ const blockquoteText = blockquote ? blockquote[0] : '';
435
+ const updated = content.replace(/## Project scenario\n[\s\S]*?\n## /, `${prefix}${blockquoteText}\n${options.scenario}\n${suffix}`);
436
+ writeFileSync(capabilityPath, updated, 'utf8');
437
+ }
438
438
  return { slug, status: status.status, scenario: options.scenario, source: options.source };
439
- }
440
-
441
- export function getCapabilityStatus(slug, root = findRoot()) {
442
- const { paths } = loadProject(root);
443
- const dir = join(paths.capabilities, slug);
444
- const statusPath = join(dir, 'status.json');
445
- if (!existsSync(statusPath)) return { slug, status: 'legacy' };
446
- return { ...readJson(statusPath, 'status.json'), slug };
447
- }
448
-
449
- export function addDeliverable(slug, options = {}, root = findRoot()) {
450
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Deliverable slug must use lowercase letters, numbers, and hyphens');
451
- if (!options.title) throw new Error('Deliverable requires --title');
452
- if (!options.kind) throw new Error('Deliverable requires --kind (module, feature, behavior, interface, artifact, operational, verification, or other)');
453
- const { paths, deliverableStore } = loadProject(root);
454
- if (deliverableStore.deliverables.some((item) => item.slug === slug)) throw new Error(`Deliverable already exists: ${slug}`);
455
- const id = nextId(deliverableStore.deliverables, 'DLV');
456
- deliverableStore.deliverables.push({ id, slug, title: options.title, kind: options.kind, notes: options.notes || '', covered_by: [], created_at: now() });
457
- atomicJson(paths.deliverables, deliverableStore);
458
- return { id, slug, title: options.title, kind: options.kind };
459
- }
460
-
461
- export function listDeliverables(root = findRoot()) {
462
- const { deliverableStore } = loadProject(root);
463
- return deliverableStore.deliverables.map((item) => ({ id: item.id, slug: item.slug, title: item.title, kind: item.kind, covered: item.covered_by.length > 0 }));
464
- }
465
-
466
- export function checkDeliverableCoverage(root = findRoot()) {
467
- const { paths, taskStore, deliverableStore } = loadProject(root);
468
- const taskCovers = new Map();
469
- for (const task of taskStore.tasks) {
470
- for (const dlvId of task.covers || []) {
471
- if (!taskCovers.has(dlvId)) taskCovers.set(dlvId, []);
472
- taskCovers.get(dlvId).push(task.id);
473
- }
474
- }
475
- const uncovered = [];
476
- const covered = [];
477
- for (const dlv of deliverableStore.deliverables) {
478
- const tasks = taskCovers.get(dlv.id) || [];
479
- if (tasks.length === 0) uncovered.push({ id: dlv.id, slug: dlv.slug, title: dlv.title });
480
- else covered.push({ id: dlv.id, slug: dlv.slug, tasks });
481
- }
482
- let changed = false;
483
- for (const dlv of deliverableStore.deliverables) {
484
- const tasks = taskCovers.get(dlv.id) || [];
485
- const sorted = [...tasks].sort();
486
- const current = JSON.stringify(dlv.covered_by);
487
- const newVal = JSON.stringify(sorted);
488
- if (current !== newVal) { dlv.covered_by = sorted; changed = true; }
489
- }
490
- if (changed) atomicJson(paths.deliverables, deliverableStore);
491
- return { total: deliverableStore.deliverables.length, covered: covered.length, uncovered: uncovered.length, uncovered_items: uncovered, covered_items: covered };
492
- }
493
-
494
- function capabilityPath(paths, name) {
495
- const dirCapability = join(paths.capabilities, name, 'capability.md');
496
- if (existsSync(dirCapability)) return dirCapability;
497
- return join(paths.capabilities, name);
498
- }
499
-
500
- function capabilityTemplateResidue(content) {
501
- return content.includes('Name the established field, what expertise it contributes')
502
- || content.includes('<node name>')
503
- || content.includes('<which research material or expert narrative supports this node>');
504
- }
505
-
506
- function extractCapabilityNode(paths, nodeRef) {
507
- const parts = nodeRef.split('#');
508
- if (parts.length !== 2) return null;
509
- const [slug, nodeId] = parts;
510
- const capPath = capabilityPath(paths, slug);
511
- if (!existsSync(capPath)) return null;
512
- const content = readFileSync(capPath, 'utf8');
513
- const header = `### ${nodeId}:`;
514
- const headerIndex = content.indexOf(header);
515
- if (headerIndex === -1) return null;
516
- const afterHeader = content.slice(headerIndex + header.length);
517
- const nextNode = afterHeader.search(/### C\d+:/);
518
- const nextSection = afterHeader.search(/\n## /);
519
- let endIdx = afterHeader.length;
520
- if (nextNode !== -1) endIdx = Math.min(endIdx, nextNode);
521
- if (nextSection !== -1) endIdx = Math.min(endIdx, nextSection);
522
- return `${header}${afterHeader.slice(0, endIdx)}`;
523
- }
524
-
525
- export function importTasks(payload, root = findRoot()) {
526
- const { paths, taskStore } = loadProject(root);
527
- const incoming = Array.isArray(payload) ? payload : payload.tasks;
528
- if (!Array.isArray(incoming) || !incoming.length) throw new Error('Task plan requires a non-empty tasks array');
529
- for (const raw of incoming) {
530
- if (raw.status && raw.status !== 'open') throw new Error('New Work Map Tasks must start open; completion requires Task evidence');
531
- const id = raw.id || nextId(taskStore.tasks, 'TASK');
532
- if (taskStore.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
533
- taskStore.tasks.push({
534
- id,
535
- title: raw.title,
536
- outcome: raw.outcome,
537
- acceptance: raw.acceptance || [],
538
- done_when: raw.done_when || [],
539
- boundaries: raw.boundaries || [],
540
- depends_on: raw.depends_on || [],
541
- reads: raw.reads || ['.loom/PROJECT.md'],
439
+ }
440
+
441
+ export function getCapabilityStatus(slug, root = findRoot()) {
442
+ const { paths } = loadProject(root);
443
+ const dir = join(paths.capabilities, slug);
444
+ const statusPath = join(dir, 'status.json');
445
+ if (!existsSync(statusPath)) return { slug, status: 'legacy' };
446
+ return { ...readJson(statusPath, 'status.json'), slug };
447
+ }
448
+
449
+ export function addDeliverable(slug, options = {}, root = findRoot()) {
450
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Deliverable slug must use lowercase letters, numbers, and hyphens');
451
+ if (!options.title) throw new Error('Deliverable requires --title');
452
+ if (!options.kind) throw new Error('Deliverable requires --kind (module, feature, behavior, interface, artifact, operational, verification, or other)');
453
+ const { paths, deliverableStore } = loadProject(root);
454
+ if (deliverableStore.deliverables.some((item) => item.slug === slug)) throw new Error(`Deliverable already exists: ${slug}`);
455
+ const id = nextId(deliverableStore.deliverables, 'DLV');
456
+ deliverableStore.deliverables.push({ id, slug, title: options.title, kind: options.kind, notes: options.notes || '', covered_by: [], created_at: now() });
457
+ atomicJson(paths.deliverables, deliverableStore);
458
+ return { id, slug, title: options.title, kind: options.kind };
459
+ }
460
+
461
+ export function listDeliverables(root = findRoot()) {
462
+ const { deliverableStore } = loadProject(root);
463
+ return deliverableStore.deliverables.map((item) => ({ id: item.id, slug: item.slug, title: item.title, kind: item.kind, covered: item.covered_by.length > 0 }));
464
+ }
465
+
466
+ export function checkDeliverableCoverage(root = findRoot()) {
467
+ const { paths, taskStore, deliverableStore } = loadProject(root);
468
+ const taskCovers = new Map();
469
+ for (const task of taskStore.tasks) {
470
+ for (const dlvId of task.covers || []) {
471
+ if (!taskCovers.has(dlvId)) taskCovers.set(dlvId, []);
472
+ taskCovers.get(dlvId).push(task.id);
473
+ }
474
+ }
475
+ const uncovered = [];
476
+ const covered = [];
477
+ for (const dlv of deliverableStore.deliverables) {
478
+ const tasks = taskCovers.get(dlv.id) || [];
479
+ if (tasks.length === 0) uncovered.push({ id: dlv.id, slug: dlv.slug, title: dlv.title });
480
+ else covered.push({ id: dlv.id, slug: dlv.slug, tasks });
481
+ }
482
+ let changed = false;
483
+ for (const dlv of deliverableStore.deliverables) {
484
+ const tasks = taskCovers.get(dlv.id) || [];
485
+ const sorted = [...tasks].sort();
486
+ const current = JSON.stringify(dlv.covered_by);
487
+ const newVal = JSON.stringify(sorted);
488
+ if (current !== newVal) { dlv.covered_by = sorted; changed = true; }
489
+ }
490
+ if (changed) atomicJson(paths.deliverables, deliverableStore);
491
+ const taskById = new Map(taskStore.tasks.map((task) => [task.id, task]));
492
+ const delivered = covered.filter((item) => item.tasks.some((id) => taskById.get(id)?.status === 'done'));
493
+ return { total: deliverableStore.deliverables.length, covered: covered.length, delivered: delivered.length, uncovered: uncovered.length, uncovered_items: uncovered, covered_items: covered };
494
+ }
495
+
496
+ function capabilityPath(paths, name) {
497
+ const dirCapability = join(paths.capabilities, name, 'capability.md');
498
+ if (existsSync(dirCapability)) return dirCapability;
499
+ return join(paths.capabilities, name);
500
+ }
501
+
502
+ function capabilityTemplateResidue(content) {
503
+ return content.includes('Name the established field, what expertise it contributes')
504
+ || content.includes('<node name>')
505
+ || content.includes('<which research material or expert narrative supports this node>');
506
+ }
507
+
508
+ function extractCapabilityNode(paths, nodeRef) {
509
+ const parts = nodeRef.split('#');
510
+ if (parts.length !== 2) return null;
511
+ const [slug, nodeId] = parts;
512
+ const capPath = capabilityPath(paths, slug);
513
+ if (!existsSync(capPath)) return null;
514
+ const content = readFileSync(capPath, 'utf8');
515
+ const header = `### ${nodeId}:`;
516
+ const headerIndex = content.indexOf(header);
517
+ if (headerIndex === -1) return null;
518
+ const afterHeader = content.slice(headerIndex + header.length);
519
+ const nextNode = afterHeader.search(/### C\d+:/);
520
+ const nextSection = afterHeader.search(/\n## /);
521
+ let endIdx = afterHeader.length;
522
+ if (nextNode !== -1) endIdx = Math.min(endIdx, nextNode);
523
+ if (nextSection !== -1) endIdx = Math.min(endIdx, nextSection);
524
+ return `${header}${afterHeader.slice(0, endIdx)}`;
525
+ }
526
+
527
+ export function importTasks(payload, root = findRoot()) {
528
+ const { paths, taskStore } = loadProject(root);
529
+ const incoming = Array.isArray(payload) ? payload : payload.tasks;
530
+ if (!Array.isArray(incoming) || !incoming.length) throw new Error('Task plan requires a non-empty tasks array');
531
+ for (const raw of incoming) {
532
+ if (raw.status && raw.status !== 'open') throw new Error('New Work Map Tasks must start open; completion requires Task evidence');
533
+ const id = raw.id || nextId(taskStore.tasks, 'TASK');
534
+ if (taskStore.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
535
+ taskStore.tasks.push({
536
+ id,
537
+ title: raw.title,
538
+ outcome: raw.outcome,
539
+ acceptance: raw.acceptance || [],
540
+ done_when: raw.done_when || [],
541
+ boundaries: raw.boundaries || [],
542
+ depends_on: raw.depends_on || [],
543
+ reads: raw.reads || ['.loom/PROJECT.md'],
542
544
  touches: raw.touches || [],
543
545
  implements: raw.implements || '',
544
546
  design_exemption: raw.design_exemption || '',
545
547
  capability_hooks: raw.capability_hooks || [],
546
548
  capability_exemption: raw.capability_exemption || '',
547
549
  integrity_version: 1,
548
- covers: raw.covers || [],
549
- status: 'open',
550
- progress: raw.progress || { completed: [], current: '', next: '' },
551
- evidence: raw.evidence || [],
552
- created_at: now(),
553
- updated_at: now(),
554
- });
555
- }
556
- validateTasks(taskStore.tasks);
557
- atomicJson(paths.tasks, taskStore);
558
- return taskSummary(taskStore.tasks);
559
- }
560
-
561
- export function taskSummary(tasks) {
562
- return {
563
- total: tasks.length,
564
- open: tasks.filter((task) => task.status === 'open').length,
565
- active: tasks.find((task) => task.status === 'active')?.id || null,
566
- blocked: tasks.filter((task) => task.status === 'blocked').length,
567
- done: tasks.filter((task) => task.status === 'done').length,
568
- };
569
- }
570
-
571
- export function getTask(id, root = findRoot()) {
572
- const { taskStore } = loadProject(root);
573
- const task = id ? taskStore.tasks.find((item) => item.id === id) : nextTask(taskStore.tasks);
574
- if (!task) throw new Error(id ? `Task not found: ${id}` : 'No executable Task is available');
575
- return task;
576
- }
577
-
550
+ covers: raw.covers || [],
551
+ status: 'open',
552
+ progress: raw.progress || { completed: [], current: '', next: '' },
553
+ evidence: raw.evidence || [],
554
+ created_at: now(),
555
+ updated_at: now(),
556
+ });
557
+ }
558
+ validateTasks(taskStore.tasks);
559
+ atomicJson(paths.tasks, taskStore);
560
+ return taskSummary(taskStore.tasks);
561
+ }
562
+
563
+ export function taskSummary(tasks) {
564
+ return {
565
+ total: tasks.length,
566
+ open: tasks.filter((task) => task.status === 'open').length,
567
+ active: tasks.find((task) => task.status === 'active')?.id || null,
568
+ blocked: tasks.filter((task) => task.status === 'blocked').length,
569
+ done: tasks.filter((task) => task.status === 'done').length,
570
+ };
571
+ }
572
+
573
+ export function getTask(id, root = findRoot()) {
574
+ const { taskStore } = loadProject(root);
575
+ const task = id ? taskStore.tasks.find((item) => item.id === id) : nextTask(taskStore.tasks);
576
+ if (!task) throw new Error(id ? `Task not found: ${id}` : 'No executable Task is available');
577
+ return task;
578
+ }
579
+
578
580
  function nextTask(tasks) {
579
- const active = tasks.find((task) => task.status === 'active');
580
- if (active) return active;
581
- const done = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.id));
582
- return tasks.find((task) => task.status === 'open' && task.depends_on.every((id) => done.has(id))) || null;
581
+ const active = tasks.find((task) => task.status === 'active');
582
+ if (active) return active;
583
+ const done = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.id));
584
+ return tasks.find((task) => task.status === 'open' && task.depends_on.every((id) => done.has(id))) || null;
585
+ }
586
+
587
+ function sectionHeadingExists(content, anchor) {
588
+ const needle = anchor.toLowerCase();
589
+ return content.split(/\r?\n/).some((line) => /^#{1,6}\s/.test(line) && line.replace(/^#{1,6}\s*/, '').toLowerCase().includes(needle));
583
590
  }
584
591
 
585
- function taskIntegrityFindings(task, designs, capabilities) {
592
+ function taskIntegrityFindings(task, designs, capabilities, paths) {
586
593
  if (task.integrity_version !== 1) return [];
587
594
  const findings = [];
588
595
  if (designs.length && !task.implements?.trim() && !task.design_exemption?.trim()) findings.push('is missing implements or design_exemption');
589
596
  if (capabilities.length && !(task.capability_hooks || []).length && !task.capability_exemption?.trim()) findings.push('is missing capability_hooks or capability_exemption');
597
+ if (!paths) return findings;
598
+ if (task.implements?.trim()) {
599
+ const raw = task.implements.trim();
600
+ const [ref, anchor] = raw.split('#');
601
+ let target = null;
602
+ try {
603
+ target = declaredArtifactPath(paths, ref);
604
+ } catch {
605
+ findings.push(`implements uses an unsafe path: ${raw}`);
606
+ }
607
+ if (target && existsSync(target)) {
608
+ if (anchor?.trim() && !sectionHeadingExists(readFileSync(target, 'utf8'), anchor.trim())) findings.push(`implements references a missing design section: ${anchor.trim()}`);
609
+ } else if (target && (ref.includes('/') || ref.includes('\\') || ref.includes('.'))) {
610
+ findings.push(`implements references a missing design document: ${ref}`);
611
+ } else if (target) {
612
+ const corpus = [paths.decisions, ...designs.map((name) => join(paths.design, name)), ...capabilities.map((name) => capabilityPath(paths, name))]
613
+ .filter((path) => existsSync(path))
614
+ .map((path) => readFileSync(path, 'utf8'))
615
+ .join('\n');
616
+ if (!corpus.includes(raw)) findings.push(`implements references a decision not found in project truth: ${raw}`);
617
+ }
618
+ }
619
+ for (const hook of task.capability_hooks || []) {
620
+ const [slug, node] = (hook.node || '').split('#');
621
+ if (!capabilities.includes(slug)) {
622
+ findings.push(`capability_hooks references a missing dossier: ${slug}`);
623
+ continue;
624
+ }
625
+ if (node && !sectionHeadingExists(readFileSync(capabilityPath(paths, slug), 'utf8'), node)) findings.push(`capability_hooks references a missing capability node: ${hook.node}`);
626
+ }
590
627
  return findings;
591
628
  }
592
629
 
593
630
  function assertTaskIntegrityClassification(task, root) {
594
- const findings = taskIntegrityFindings(task, listDesigns(root), listCapabilities(root));
595
- if (findings.length) throw new Error(`${task.id} has unresolved integrity classification:\n- ${findings.join('\n- ')}\nAdd the relevant link or a concrete exemption before execution.`);
631
+ const { paths } = loadProject(root);
632
+ const findings = taskIntegrityFindings(task, listDesigns(root), listCapabilities(root), paths);
633
+ if (findings.length) throw new Error(`${task.id} has unresolved integrity classification:\n- ${findings.join('\n- ')}\nRepair the reference or add a concrete exemption before execution.`);
596
634
  }
597
635
 
598
636
  function declaredArtifactPath(paths, ref) {
@@ -613,351 +651,380 @@ function missingTaskOutputs(task, paths) {
613
651
  }
614
652
 
615
653
  export function updateTask(id, patch, root = findRoot()) {
616
- const { paths, taskStore } = loadProject(root);
617
- const task = taskStore.tasks.find((item) => item.id === id);
618
- if (!task) throw new Error(`Task not found: ${id}`);
654
+ const { paths, taskStore } = loadProject(root);
655
+ const task = taskStore.tasks.find((item) => item.id === id);
656
+ if (!task) throw new Error(`Task not found: ${id}`);
657
+ if (task.status === 'done') throw new Error(`${id} is done; reopen it first (loom task reopen ${id} --reason <why the completion is being revisited>) before changing its record`);
619
658
  const allowed = ['title', 'outcome', 'acceptance', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'implements', 'design_exemption', 'capability_hooks', 'capability_exemption', 'covers', 'progress', 'evidence'];
620
- for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
621
- Object.assign(task, patch, { updated_at: now() });
622
- validateTasks(taskStore.tasks);
623
- atomicJson(paths.tasks, taskStore);
624
- return task;
625
- }
626
-
627
- export function blockTask(id, payload, root = findRoot()) {
628
- const task = getTask(id, root);
629
- if (task.status !== 'active') throw new Error(`${id} is not active`);
630
- if (!payload.reason || payload.reason.length < 10) throw new Error('Blocking a Task requires a concrete reason');
631
- if (!Array.isArray(payload.recovery_conditions) || !payload.recovery_conditions.length) throw new Error('Blocking a Task requires recovery_conditions');
632
- const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
633
- const { paths, taskStore } = loadProject(root);
634
- const target = taskStore.tasks.find((item) => item.id === id);
635
- target.status = 'blocked';
636
- target.block = { reason: payload.reason, recovery_conditions: payload.recovery_conditions, at: now() };
637
- target.progress = { ...target.progress, current: `blocked: ${payload.reason}`, next: payload.recovery_conditions.join('; ') };
638
- target.evidence = [...target.evidence, ...evidence];
639
- target.updated_at = now();
640
- validateTasks(taskStore.tasks);
641
- atomicJson(paths.tasks, taskStore);
642
- return target;
643
- }
644
-
645
- export function reopenTask(id, options = {}, root = findRoot()) {
646
- const { paths, taskStore } = loadProject(root);
647
- if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
648
- const task = taskStore.tasks.find((item) => item.id === id);
649
- if (!task || !['blocked', 'done'].includes(task.status)) throw new Error(`${id} is neither blocked nor done`);
650
- if (task.status === 'done' && (!options.reason || options.reason.length < 10)) throw new Error('Reopening a done Task requires a concrete --reason');
651
- const priorStatus = task.status;
652
- task.status = 'open';
653
- if (priorStatus === 'done') {
654
- task.evidence.push({ type: 'completion_reopened', reason: options.reason, at: now() });
655
- task.progress = { ...task.progress, current: `completion reopened: ${options.reason}`, next: 'Re-run the Task and close every done condition with evidence.' };
656
- }
657
- task.reopened_at = now();
658
- task.updated_at = now();
659
- validateTasks(taskStore.tasks);
660
- atomicJson(paths.tasks, taskStore);
661
- const { state } = loadProject(root);
662
- if (state.project.status === 'complete') {
663
- state.project.status = 'building';
664
- state.project.updated_at = now();
665
- atomicJson(paths.state, state);
666
- }
667
- return task;
668
- }
669
-
659
+ for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
660
+ Object.assign(task, patch, { updated_at: now() });
661
+ validateTasks(taskStore.tasks);
662
+ atomicJson(paths.tasks, taskStore);
663
+ return task;
664
+ }
665
+
666
+ export function blockTask(id, payload, root = findRoot()) {
667
+ const task = getTask(id, root);
668
+ if (task.status !== 'active') throw new Error(`${id} is not active`);
669
+ if (!payload.reason || payload.reason.length < 10) throw new Error('Blocking a Task requires a concrete reason');
670
+ if (!Array.isArray(payload.recovery_conditions) || !payload.recovery_conditions.length) throw new Error('Blocking a Task requires recovery_conditions');
671
+ const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
672
+ const { paths, taskStore } = loadProject(root);
673
+ const target = taskStore.tasks.find((item) => item.id === id);
674
+ target.status = 'blocked';
675
+ target.block = { reason: payload.reason, recovery_conditions: payload.recovery_conditions, at: now() };
676
+ target.progress = { ...target.progress, current: `blocked: ${payload.reason}`, next: payload.recovery_conditions.join('; ') };
677
+ target.evidence = [...target.evidence, ...evidence];
678
+ target.updated_at = now();
679
+ validateTasks(taskStore.tasks);
680
+ atomicJson(paths.tasks, taskStore);
681
+ return target;
682
+ }
683
+
684
+ export function reopenTask(id, options = {}, root = findRoot()) {
685
+ const { paths, taskStore } = loadProject(root);
686
+ if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
687
+ const task = taskStore.tasks.find((item) => item.id === id);
688
+ if (!task || !['blocked', 'done'].includes(task.status)) throw new Error(`${id} is neither blocked nor done`);
689
+ if (!options.reason || options.reason.length < 10) throw new Error(`Reopening ${id} requires a concrete --reason — for blocked Tasks state how the recovery conditions were met, for done Tasks why the completion is disproved`);
690
+ const priorStatus = task.status;
691
+ task.status = 'open';
692
+ if (priorStatus === 'done') {
693
+ task.evidence.push({ type: 'completion_reopened', reason: options.reason, at: now() });
694
+ task.progress = { ...task.progress, current: `completion reopened: ${options.reason}`, next: 'Re-run the Task and close every done condition with evidence.' };
695
+ } else {
696
+ task.evidence.push({ type: 'block_reopened', reason: options.reason, at: now() });
697
+ task.progress = { ...task.progress, current: `block reopened: ${options.reason}`, next: 'Address the recorded recovery conditions and re-verify before completing.' };
698
+ }
699
+ task.reopened_at = now();
700
+ task.updated_at = now();
701
+ validateTasks(taskStore.tasks);
702
+ atomicJson(paths.tasks, taskStore);
703
+ const { state } = loadProject(root);
704
+ if (state.project.status === 'complete') {
705
+ state.project.status = 'building';
706
+ state.project.updated_at = now();
707
+ atomicJson(paths.state, state);
708
+ }
709
+ return task;
710
+ }
711
+
670
712
  export function startTask(id, root = findRoot()) {
671
713
  const { paths, state, taskStore } = loadProject(root);
672
- if (!['passed', 'skipped'].includes(state.keeper.status)) throw new Error('The one-time Keeper handoff has not passed. Run loom keeper prompt.');
673
- if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
714
+ const latestKeeper = state.keeper.attempts.at(-1);
715
+ const attestedPass = state.keeper.status === 'passed' && latestKeeper?.review?.mode === 'independent';
716
+ if (state.keeper.status === 'passed' && !attestedPass) throw new Error('The recorded Keeper pass has no independent review provenance. Obtain a fresh Keeper review (see loom review --help) or record an explicit skip: loom keeper skip --reason <limitation>.');
717
+ if (!['passed', 'skipped'].includes(state.keeper.status)) throw new Error('The one-time Keeper handoff has not passed. Run loom keeper prompt.');
718
+ if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
674
719
  const task = taskStore.tasks.find((item) => item.id === id);
675
720
  if (!task || task.status !== 'open') throw new Error(`${id} is not open`);
676
721
  assertTaskIntegrityClassification(task, root);
677
- const done = new Set(taskStore.tasks.filter((item) => item.status === 'done').map((item) => item.id));
678
- const missing = task.depends_on.filter((dep) => !done.has(dep));
679
- if (missing.length) throw new Error(`${id} has incomplete dependencies: ${missing.join(', ')}`);
680
- for (const ref of task.reads) readContextDocument(paths, ref);
681
- task.status = 'active';
682
- task.updated_at = now();
683
- validateTasks(taskStore.tasks);
684
- atomicJson(paths.tasks, taskStore);
685
- state.project.status = 'building';
686
- state.project.updated_at = now();
687
- atomicJson(paths.state, state);
688
- return task;
689
- }
690
-
722
+ const done = new Set(taskStore.tasks.filter((item) => item.status === 'done').map((item) => item.id));
723
+ const missing = task.depends_on.filter((dep) => !done.has(dep));
724
+ if (missing.length) throw new Error(`${id} has incomplete dependencies: ${missing.join(', ')}`);
725
+ for (const ref of task.reads) readContextDocument(paths, ref);
726
+ task.status = 'active';
727
+ task.updated_at = now();
728
+ validateTasks(taskStore.tasks);
729
+ atomicJson(paths.tasks, taskStore);
730
+ state.project.status = 'building';
731
+ state.project.updated_at = now();
732
+ atomicJson(paths.state, state);
733
+ return task;
734
+ }
735
+
691
736
  export function completeTask(id, payload, root = findRoot()) {
692
- const { paths, state, taskStore } = loadProject(root);
693
- const task = taskStore.tasks.find((item) => item.id === id);
694
- if (!task) throw new Error(`Task not found: ${id}`);
737
+ const { paths, state, taskStore } = loadProject(root);
738
+ const task = taskStore.tasks.find((item) => item.id === id);
739
+ if (!task) throw new Error(`Task not found: ${id}`);
695
740
  if (task.status !== 'active') throw new Error(`${id} is not active`);
696
741
  if (!Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Completing a Task requires concrete evidence');
697
742
  assertTaskIntegrityClassification(task, root);
698
743
  const missingOutputs = missingTaskOutputs(task, paths);
699
744
  if (missingOutputs.length) throw new Error(`${id} declared output does not exist:\n- ${missingOutputs.join('\n- ')}`);
700
- const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
701
- if (hasAcceptance) {
702
- if (!Array.isArray(payload.acceptance_results)) throw new Error('Completing a Task with acceptance[] requires acceptance_results[]');
703
- const criteria = new Set(task.acceptance.map((acc) => acc.criterion));
704
- const seen = new Set();
705
- for (const result of payload.acceptance_results) {
706
- if (!result || !criteria.has(result.criterion)) throw new Error('acceptance_results criterion must match an exact acceptance criterion');
707
- if (seen.has(result.criterion)) throw new Error(`Duplicate acceptance result: ${result.criterion}`);
708
- if (!result.evidence || typeof result.evidence !== 'string' || result.evidence.length < 5) throw new Error(`acceptance result requires concrete evidence: ${result.criterion}`);
709
- seen.add(result.criterion);
710
- }
711
- const missingResults = task.acceptance.filter((acc) => !seen.has(acc.criterion));
712
- if (missingResults.length) throw new Error(`Task completion is missing acceptance results:\n- ${missingResults.map((acc) => acc.criterion).join('\n- ')}`);
713
- for (const result of payload.acceptance_results) {
714
- const acc = task.acceptance.find((acc) => acc.criterion === result.criterion);
715
- acc.evidence = result.evidence;
716
- }
717
- task.status = 'done';
718
- task.evidence = [...task.evidence, ...payload.evidence, { type: 'acceptance_results', results: payload.acceptance_results, at: now() }];
719
- } else {
720
- if (!Array.isArray(payload.checks)) throw new Error('Completing a Task with done_when[] requires checks for every done_when criterion');
721
- const criteria = new Set(task.done_when);
722
- const seen = new Set();
723
- for (const check of payload.checks) {
724
- if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
725
- if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
726
- if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
727
- seen.add(check.criterion);
728
- }
729
- const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
730
- if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
731
- task.status = 'done';
732
- task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
733
- }
734
- task.progress = { ...task.progress, current: 'complete', next: '' };
735
- task.updated_at = now();
736
- validateTasks(taskStore.tasks);
737
- atomicJson(paths.tasks, taskStore);
738
- if (taskStore.tasks.length && taskStore.tasks.every((item) => item.status === 'done')) {
739
- state.project.status = 'complete';
740
- state.project.updated_at = now();
741
- atomicJson(paths.state, state);
742
- }
743
- return task;
744
- }
745
-
746
- export function markReady(root = findRoot()) {
747
- const { paths, state, taskStore } = loadProject(root);
748
- const project = readFileSync(paths.project, 'utf8');
749
- const designs = listDesigns(root);
750
- const capabilities = listCapabilities(root);
751
- const highOpen = state.understanding.unresolved.filter((item) => item.status === 'open' && item.impact === 'high');
752
- const errors = [];
753
- if (project.includes('> This is the concise entry point') || project.length < 400) errors.push('PROJECT.md still looks like a template');
754
- if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
755
- 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.'));
756
- if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
757
- const unfinishedCapabilities = capabilities.filter((name) => capabilityTemplateResidue(readFileSync(capabilityPath(paths, name), 'utf8')));
758
- if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
759
- if (!taskStore.tasks.length) errors.push('The initial work map is empty');
760
- if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
761
- const digest = projectDigest(paths, taskStore.tasks);
762
- const latestKeeper = state.keeper.attempts.at(-1);
763
- if (['needs_revision', 'blocked'].includes(state.keeper.status) && latestKeeper?.prepared_digest === digest) {
764
- errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
765
- }
766
- if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
767
- if (latestKeeper?.can_auto_pass && latestKeeper.prepared_digest !== digest) {
768
- state.keeper.status = 'passed';
769
- state.project.status = 'build_ready';
770
- state.project.updated_at = now();
771
- state.keeper.auto_passed = true;
772
- atomicJson(paths.state, state);
773
- return { ready_for_keeper: false, auto_passed: true, next: 'Keeper minor gaps fixed; auto-passed without a new Keeper round' };
774
- }
775
- state.project.status = 'ready_for_keeper';
776
- state.project.updated_at = now();
777
- state.keeper.prepared_digest = digest;
778
- state.keeper.prepared_attempt = state.keeper.attempts.length + 1;
779
- atomicJson(paths.state, state);
780
- return { ready_for_keeper: true, digest: state.keeper.prepared_digest, next: 'Open a fresh Agent and run loom keeper prompt' };
781
- }
782
-
783
- function projectDigest(paths, tasks) {
784
- const hash = createHash('sha256');
785
- hash.update(readFileSync(paths.project));
786
- hash.update(readFileSync(paths.decisions));
787
- for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
788
- for (const name of listCapabilities()) {
789
- hash.update(readFileSync(capabilityPath(paths, name), 'utf8'));
790
- const statusPath = join(paths.capabilities, name, 'status.json');
791
- if (existsSync(statusPath)) hash.update(readFileSync(statusPath, 'utf8'));
792
- }
793
- hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
794
- return hash.digest('hex');
795
- }
796
-
797
- export function getKeeperPrompt(root = findRoot()) {
798
- const { state } = loadProject(root);
799
- if (state.project.status !== 'ready_for_keeper') throw new Error('Run loom project ready before Keeper handoff');
800
- return keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest });
801
- }
802
-
745
+ const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
746
+ if (hasAcceptance) {
747
+ if (!Array.isArray(payload.acceptance_results)) throw new Error('Completing a Task with acceptance[] requires acceptance_results[]');
748
+ const criteria = new Set(task.acceptance.map((acc) => acc.criterion));
749
+ const seen = new Set();
750
+ for (const result of payload.acceptance_results) {
751
+ if (!result || !criteria.has(result.criterion)) throw new Error('acceptance_results criterion must match an exact acceptance criterion');
752
+ if (seen.has(result.criterion)) throw new Error(`Duplicate acceptance result: ${result.criterion}`);
753
+ if (!result.evidence || typeof result.evidence !== 'string' || result.evidence.length < 5) throw new Error(`acceptance result requires concrete evidence: ${result.criterion}`);
754
+ seen.add(result.criterion);
755
+ }
756
+ const missingResults = task.acceptance.filter((acc) => !seen.has(acc.criterion));
757
+ if (missingResults.length) throw new Error(`Task completion is missing acceptance results:\n- ${missingResults.map((acc) => acc.criterion).join('\n- ')}`);
758
+ for (const result of payload.acceptance_results) {
759
+ const acc = task.acceptance.find((acc) => acc.criterion === result.criterion);
760
+ acc.evidence = result.evidence;
761
+ }
762
+ task.status = 'done';
763
+ task.evidence = [...task.evidence, ...payload.evidence, { type: 'acceptance_results', results: payload.acceptance_results, at: now() }];
764
+ } else {
765
+ if (!Array.isArray(payload.checks)) throw new Error('Completing a Task with done_when[] requires checks for every done_when criterion');
766
+ const criteria = new Set(task.done_when);
767
+ const seen = new Set();
768
+ for (const check of payload.checks) {
769
+ if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
770
+ if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
771
+ if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
772
+ seen.add(check.criterion);
773
+ }
774
+ const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
775
+ if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
776
+ task.status = 'done';
777
+ task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
778
+ }
779
+ task.progress = { ...task.progress, current: 'complete', next: '' };
780
+ task.completed_at = now();
781
+ task.updated_at = now();
782
+ validateTasks(taskStore.tasks);
783
+ atomicJson(paths.tasks, taskStore);
784
+ if (taskStore.tasks.length && taskStore.tasks.every((item) => item.status === 'done')) {
785
+ state.project.status = 'complete';
786
+ state.project.updated_at = now();
787
+ atomicJson(paths.state, state);
788
+ }
789
+ return task;
790
+ }
791
+
792
+ export function markReady(root = findRoot()) {
793
+ const { paths, state, taskStore } = loadProject(root);
794
+ const project = readFileSync(paths.project, 'utf8');
795
+ const designs = listDesigns(root);
796
+ const capabilities = listCapabilities(root);
797
+ const highOpen = state.understanding.unresolved.filter((item) => item.status === 'open' && item.impact === 'high');
798
+ const errors = [];
799
+ if (project.includes('> This is the concise entry point') || project.length < 400) errors.push('PROJECT.md still looks like a template');
800
+ if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
801
+ 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.'));
802
+ if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
803
+ const unfinishedCapabilities = capabilities.filter((name) => capabilityTemplateResidue(readFileSync(capabilityPath(paths, name), 'utf8')));
804
+ if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
805
+ if (!taskStore.tasks.length) errors.push('The initial work map is empty');
806
+ const integrityErrors = [];
807
+ for (const task of taskStore.tasks) {
808
+ for (const finding of taskIntegrityFindings(task, designs, capabilities, paths)) integrityErrors.push(`${task.id} ${finding}`);
809
+ }
810
+ if (integrityErrors.length) errors.push(`Task integrity classification is unresolved:\n - ${integrityErrors.join('\n - ')}`);
811
+ if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
812
+ const digest = projectDigest(paths, taskStore.tasks);
813
+ const latestKeeper = state.keeper.attempts.at(-1);
814
+ if (['needs_revision', 'blocked'].includes(state.keeper.status) && latestKeeper?.prepared_digest === digest) {
815
+ errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
816
+ }
817
+ if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
818
+ // A changed digest proves a change, not that review findings were resolved.
819
+ delete state.keeper.auto_passed;
820
+ state.project.status = 'ready_for_keeper';
821
+ state.project.updated_at = now();
822
+ state.keeper.prepared_digest = digest;
823
+ state.keeper.prepared_attempt = state.keeper.attempts.length + 1;
824
+ atomicJson(paths.state, state);
825
+ return { ready_for_keeper: true, digest: state.keeper.prepared_digest, next: 'Open a fresh Agent and run loom keeper prompt' };
826
+ }
827
+
828
+ function projectDigest(paths, tasks) {
829
+ const hash = createHash('sha256');
830
+ hash.update(readFileSync(paths.project));
831
+ hash.update(readFileSync(paths.decisions));
832
+ for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
833
+ for (const name of listCapabilities()) {
834
+ hash.update(readFileSync(capabilityPath(paths, name), 'utf8'));
835
+ const statusPath = join(paths.capabilities, name, 'status.json');
836
+ if (existsSync(statusPath)) hash.update(readFileSync(statusPath, 'utf8'));
837
+ }
838
+ hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
839
+ return hash.digest('hex');
840
+ }
841
+
842
+ export function getKeeperPrompt(root = findRoot()) {
843
+ const { state } = loadProject(root);
844
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Run loom project ready before Keeper handoff');
845
+ return keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest });
846
+ }
847
+
803
848
  export function recordKeeper(payload, root = findRoot()) {
804
- const { paths, state, taskStore } = loadProject(root);
805
- if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper result cannot be recorded before loom project ready');
849
+ const { paths, state, taskStore } = loadProject(root);
850
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper result cannot be recorded before loom project ready');
806
851
  if (!['passed', 'needs_revision', 'blocked'].includes(payload.verdict)) throw new Error('Keeper verdict must be passed, needs_revision, or blocked');
807
852
  if (!payload.summary || !Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Keeper result requires summary and evidence');
808
853
  if (payload.verdict === 'passed') {
854
+ if (payload.gaps?.length) throw new Error('Keeper pass cannot contain unresolved gaps');
809
855
  if (!payload.review || payload.review.mode !== 'independent') throw new Error('Keeper pass requires an independent review; use review.mode="independent" from a fresh Agent, or loom keeper skip with a concrete reason');
810
856
  if (!payload.review.reviewer_id || payload.review.reviewer_id.length < 6 || !payload.review.evidence || payload.review.evidence.length < 10) throw new Error('Independent Keeper review requires reviewer_id and concrete review evidence');
811
857
  }
812
- if (payload.evidence.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('Keeper evidence entries must be non-empty strings');
813
- if (payload.gaps !== undefined && !Array.isArray(payload.gaps)) throw new Error('Keeper gaps must be an array');
814
- for (const gap of payload.gaps || []) {
815
- if (typeof gap === 'string' && gap.trim()) continue;
816
- if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
817
- throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
818
- }
819
- const blockingGaps = (payload.gaps || []).filter((gap) => {
820
- if (typeof gap === 'string') return true;
821
- if (typeof gap === 'object' && gap.severity !== 'minor') return true;
822
- return false;
823
- });
824
- const minorGaps = (payload.gaps || []).filter((gap) => {
825
- if (typeof gap === 'object' && gap.severity === 'minor') return true;
826
- return false;
827
- });
828
- const canAutoPass = payload.verdict === 'needs_revision' && blockingGaps.length === 0 && minorGaps.length > 0 && minorGaps.length <= 3;
829
- if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
830
- if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
831
- 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');
832
- const currentDigest = projectDigest(paths, taskStore.tasks);
833
- if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
834
- const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], review: payload.review || { mode: 'unverified', reviewer_id: '', evidence: '' }, can_auto_pass: canAutoPass, at: now() };
835
- state.keeper.attempts.push(attempt);
836
- state.keeper.status = payload.verdict;
837
- if (payload.verdict === 'passed') state.project.status = 'build_ready';
838
- else state.project.status = 'shaping';
839
- state.project.updated_at = now();
840
- atomicJson(paths.state, state);
841
- return state.keeper;
842
- }
843
-
844
- export function skipKeeper(reason, root = findRoot()) {
845
- if (!reason || reason.length < 10) throw new Error('Skipping Keeper requires a concrete reason');
846
- const { paths, state } = loadProject(root);
847
- if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper can only be skipped after loom project ready');
848
- state.keeper.status = 'skipped';
849
- state.keeper.skip_reason = reason;
850
- state.project.status = 'build_ready';
851
- state.project.updated_at = now();
852
- atomicJson(paths.state, state);
853
- return state.keeper;
854
- }
855
-
856
- export function recordDecision(payload, root = findRoot()) {
857
- if (!payload || !payload.summary || payload.summary.length < 10) throw new Error('Decision requires --summary (what changed and why)');
858
- if (!payload.changes || !Array.isArray(payload.changes) || !payload.changes.length) throw new Error('Decision requires --changes (array of affected files or decisions)');
859
- const { paths } = loadProject(root);
860
- const id = `D-${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
861
- 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`;
862
- const existing = readFileSync(paths.decisions, 'utf8');
863
- const separator = existing.endsWith('\n') ? '\n' : '\n\n';
864
- writeFileSync(paths.decisions, existing + separator + entry, 'utf8');
865
- return { id, summary: payload.summary, changes: payload.changes, affected_tasks: payload.affected_tasks || [] };
866
- }
867
-
858
+ if (payload.evidence.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('Keeper evidence entries must be non-empty strings');
859
+ if (payload.gaps !== undefined && !Array.isArray(payload.gaps)) throw new Error('Keeper gaps must be an array');
860
+ for (const gap of payload.gaps || []) {
861
+ if (typeof gap === 'string' && gap.trim()) continue;
862
+ if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
863
+ throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
864
+ }
865
+
866
+ if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
867
+ if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
868
+ 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');
869
+ const currentDigest = projectDigest(paths, taskStore.tasks);
870
+ if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
871
+ if (payload.verdict === 'passed') {
872
+ const openFindings = new Map();
873
+ for (const attempt of state.keeper.attempts) {
874
+ for (const closure of attempt.closure_results || []) openFindings.delete(closure.gap);
875
+ for (const finding of attempt.gaps || []) {
876
+ const gap = typeof finding === 'string' ? finding : finding?.gap;
877
+ if (gap) openFindings.set(gap, true);
878
+ }
879
+ }
880
+ for (const gap of openFindings.keys()) {
881
+ const closure = payload.closure_results?.find((item) => item.gap === gap);
882
+ if (!closure || typeof closure.evidence !== 'string' || !closure.evidence.trim()) {
883
+ throw new Error(`Keeper pass requires closure_results evidence for: ${gap}`);
884
+ }
885
+ }
886
+ }
887
+ const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], review: payload.review || { mode: 'unverified', reviewer_id: '', evidence: '' }, can_auto_pass: false, at: now() };
888
+ state.keeper.attempts.push(attempt);
889
+ attempt.closure_results = payload.closure_results || [];
890
+ state.keeper.status = payload.verdict;
891
+ if (payload.verdict === 'passed') state.project.status = 'build_ready';
892
+ else state.project.status = 'shaping';
893
+ state.project.updated_at = now();
894
+ atomicJson(paths.state, state);
895
+ return state.keeper;
896
+ }
897
+
898
+ export function skipKeeper(reason, root = findRoot()) {
899
+ if (!reason || reason.length < 10) throw new Error('Skipping Keeper requires a concrete reason');
900
+ const { paths, state } = loadProject(root);
901
+ if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper can only be skipped after loom project ready');
902
+ state.keeper.status = 'skipped';
903
+ state.keeper.skip_reason = reason;
904
+ state.project.status = 'build_ready';
905
+ state.project.updated_at = now();
906
+ atomicJson(paths.state, state);
907
+ return state.keeper;
908
+ }
909
+
910
+ export function recordDecision(payload, root = findRoot()) {
911
+ if (!payload || !payload.summary || payload.summary.length < 10) throw new Error('Decision requires --summary (what changed and why)');
912
+ if (!payload.changes || !Array.isArray(payload.changes) || !payload.changes.length) throw new Error('Decision requires --changes (array of affected files or decisions)');
913
+ const { paths } = loadProject(root);
914
+ const id = `D-${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
915
+ 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`;
916
+ const existing = readFileSync(paths.decisions, 'utf8');
917
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
918
+ writeFileSync(paths.decisions, existing + separator + entry, 'utf8');
919
+ return { id, summary: payload.summary, changes: payload.changes, affected_tasks: payload.affected_tasks || [] };
920
+ }
921
+
868
922
  export function compileContext(options = {}, root = findRoot()) {
869
- const { paths, state, taskStore } = loadProject(root);
870
- const designs = listDesigns(root);
871
- const capabilities = listCapabilities(root);
872
- const summary = taskSummary(taskStore.tasks);
873
- const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
874
- const next = nextTask(taskStore.tasks);
875
- const recommendation = task
876
- ? `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.`
877
- : next
878
- ? `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.`
879
- : state.project.status === 'complete'
880
- ? 'All Tasks are done. Run \`loom check\` to verify health. If new work arises, record the decision and update the Work Map.'
881
- : state.project.status === 'shaping'
882
- ? 'Project is still shaping. Confirm the intended result, identify open questions, and build the Work Map before starting material work.'
883
- : 'No executable Task. Create or update Tasks so the Work Map matches the project goal.';
923
+ const { paths, state, taskStore } = loadProject(root);
924
+ const designs = listDesigns(root);
925
+ const capabilities = listCapabilities(root);
926
+ const summary = taskSummary(taskStore.tasks);
927
+ const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
928
+ const next = nextTask(taskStore.tasks);
929
+ const blockedTasks = taskStore.tasks.filter((item) => item.status === 'blocked');
930
+ const blockedLine = blockedTasks.length
931
+ ? `- Blocked: ${blockedTasks.map((item) => `${item.id} — ${item.block?.reason || 'no reason recorded'} (recover: ${(item.block?.recovery_conditions || []).join('; ') || 'not recorded'})`).join(' | ')}\n`
932
+ : '';
933
+ const recommendation = state.project.status === 'ready_for_keeper'
934
+ ? 'Keeper handoff pending. The host must open a fresh Agent without inherited conversation, provide the workspace and CLI paths, and ask it to run `loom keeper prompt`. Wait for `loom keeper record`, then resume `loom context`. See `loom review --help` for the complete handoff.'
935
+ : ['needs_revision', 'blocked'].includes(state.keeper.status)
936
+ ? 'Resolve the Keeper findings in the source documents and Tasks, run `loom project ready`, and obtain a fresh independent review. A changed digest does not prove findings are closed. See `loom review --help`.'
937
+ : task
938
+ ? `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.`
939
+ : next
940
+ ? `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.`
941
+ : state.project.status === 'complete'
942
+ ? 'All Tasks are done. Run \`loom check\` to verify health. If new work arises, record the decision and update the Work Map.'
943
+ : state.project.status === 'shaping'
944
+ ? 'Project is still shaping. Confirm the intended result, identify open questions, and build the Work Map before starting material work.'
945
+ : blockedTasks.length
946
+ ? 'No executable Task. Review the blocked Tasks listed above; reopen one when its recovery conditions are met with `loom task reopen <id> --reason <how they were met>`, or extend the Work Map.'
947
+ : 'No executable Task. Create or update Tasks so the Work Map matches the project goal.';
884
948
  const capabilityStates = capabilities.map((name) => {
885
949
  const statusPath = join(paths.capabilities, name, 'status.json');
886
950
  return existsSync(statusPath) ? `${name} (${readJson(statusPath, 'status.json').status || 'unknown'})` : `${name} (legacy)`;
887
951
  });
888
- 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: ${capabilityStates.length ? capabilityStates.join(', ') : 'none'}\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.`;
952
+ 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${blockedLine}- Design documents: ${designs.length}\n- Capability dossiers: ${capabilityStates.length ? capabilityStates.join(', ') : 'none'}\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.`;
889
953
  const blocks = [statusBlock, agentProtocol({ humanChannel: options.humanChannel || 'available' }), shapingContext({ state, taskSummary: summary, capabilityNames: capabilityStates, designNames: designs, forKeeper: Boolean(options.keeper) })];
890
- blocks.push(`## Project whole (${normalizeRef(paths, paths.project)})\n\n${readFileSync(paths.project, 'utf8')}`);
891
- if (existsSync(paths.structure)) blocks.push(`## Project structure (${normalizeRef(paths, paths.structure)})\n\n${readFileSync(paths.structure, 'utf8')}`);
892
- if (options.keeper) {
893
- blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
894
- blocks.push(`## Decision history (${normalizeRef(paths, paths.decisions)})\n\n${readFileSync(paths.decisions, 'utf8')}`);
895
- 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)}`);
896
- for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
897
- for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
898
- } else if (task) {
899
- blocks.push(EXECUTION_PROTOCOL);
900
- blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
901
- for (const ref of task.reads) {
902
- const content = readContextDocument(paths, ref);
903
- if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
904
- }
905
- if (task.capability_hooks && task.capability_hooks.length) {
906
- const hookBlocks = [];
907
- for (const hook of task.capability_hooks) {
908
- const nodeContent = extractCapabilityNode(paths, hook.node);
909
- if (nodeContent) {
910
- const atLine = hook.at ? `\n\n**Activate at:** ${hook.at}` : '';
911
- const produceLine = hook.must_produce ? `\n\n**Must produce:** ${hook.must_produce}` : '';
912
- hookBlocks.push(`### Capability hook: ${hook.node}${atLine}${produceLine}\n\n${nodeContent}`);
913
- }
914
- }
915
- 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')}`);
916
- }
917
- } else {
918
- 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.'}`);
919
- }
920
- return blocks.filter(Boolean).join('\n\n---\n\n');
921
- }
922
-
923
- function normalizeRef(paths, absolute) {
924
- const loomPrefix = `${paths.loom}${process.platform === 'win32' ? '\\' : '/'}`;
925
- if (absolute === paths.loom || absolute.startsWith(loomPrefix)) return `.loom/${relative(paths.loom, absolute).replaceAll('\\', '/')}`.replace(/\/$/, '');
926
- return relative(paths.root, absolute).replaceAll('\\', '/');
927
- }
928
-
929
- function readContextDocument(paths, ref) {
930
- const normalized = ref.replaceAll('\\', '/');
931
- if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
932
- const fromLoom = normalized === '.loom' || normalized.startsWith('.loom/');
933
- const base = fromLoom ? paths.loom : paths.root;
934
- const relativeRef = fromLoom ? normalized.slice('.loom'.length).replace(/^\//, '') : normalized;
935
- const absolute = resolve(base, relativeRef);
936
- const prefix = `${base}${process.platform === 'win32' ? '\\' : '/'}`;
937
- if (!absolute.startsWith(prefix) && absolute !== base) throw new Error(`Unsafe context reference: ${ref}`);
938
- if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
939
- if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
940
- return readFileSync(absolute, 'utf8');
941
- }
942
-
954
+ if (state.keeper.status === 'passed' && state.keeper.attempts.at(-1)?.review?.mode !== 'independent') {
955
+ blocks.splice(1, 0, 'WARNING: The recorded Keeper pass has no independent review provenance. It is a legacy/unverified pass, not verified readiness. Prepare a fresh handoff before relying on it; see `loom review --help`.');
956
+ }
957
+ blocks.push(`## Project whole (${normalizeRef(paths, paths.project)})\n\n${readFileSync(paths.project, 'utf8')}`);
958
+ if (existsSync(paths.structure)) blocks.push(`## Project structure (${normalizeRef(paths, paths.structure)})\n\n${readFileSync(paths.structure, 'utf8')}`);
959
+ if (options.keeper) {
960
+ blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
961
+ blocks.push(`## Decision history (${normalizeRef(paths, paths.decisions)})\n\n${readFileSync(paths.decisions, 'utf8')}`);
962
+ 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)}`);
963
+ for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
964
+ for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
965
+ } else if (task) {
966
+ blocks.push(EXECUTION_PROTOCOL);
967
+ blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
968
+ for (const ref of task.reads) {
969
+ const content = readContextDocument(paths, ref);
970
+ if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
971
+ }
972
+ if (task.capability_hooks && task.capability_hooks.length) {
973
+ const hookBlocks = [];
974
+ for (const hook of task.capability_hooks) {
975
+ const nodeContent = extractCapabilityNode(paths, hook.node);
976
+ if (nodeContent) {
977
+ const atLine = hook.at ? `\n\n**Activate at:** ${hook.at}` : '';
978
+ const produceLine = hook.must_produce ? `\n\n**Must produce:** ${hook.must_produce}` : '';
979
+ hookBlocks.push(`### Capability hook: ${hook.node}${atLine}${produceLine}\n\n${nodeContent}`);
980
+ }
981
+ }
982
+ 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')}`);
983
+ }
984
+ } else {
985
+ 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.'}`);
986
+ }
987
+ return blocks.filter(Boolean).join('\n\n---\n\n');
988
+ }
989
+
990
+ function normalizeRef(paths, absolute) {
991
+ const loomPrefix = `${paths.loom}${process.platform === 'win32' ? '\\' : '/'}`;
992
+ if (absolute === paths.loom || absolute.startsWith(loomPrefix)) return `.loom/${relative(paths.loom, absolute).replaceAll('\\', '/')}`.replace(/\/$/, '');
993
+ return relative(paths.root, absolute).replaceAll('\\', '/');
994
+ }
995
+
996
+ function readContextDocument(paths, ref) {
997
+ const normalized = ref.replaceAll('\\', '/');
998
+ if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
999
+ const fromLoom = normalized === '.loom' || normalized.startsWith('.loom/');
1000
+ const base = fromLoom ? paths.loom : paths.root;
1001
+ const relativeRef = fromLoom ? normalized.slice('.loom'.length).replace(/^\//, '') : normalized;
1002
+ const absolute = resolve(base, relativeRef);
1003
+ const prefix = `${base}${process.platform === 'win32' ? '\\' : '/'}`;
1004
+ if (!absolute.startsWith(prefix) && absolute !== base) throw new Error(`Unsafe context reference: ${ref}`);
1005
+ if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
1006
+ if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
1007
+ return readFileSync(absolute, 'utf8');
1008
+ }
1009
+
943
1010
  export function checkProject(root = findRoot()) {
944
1011
  const { paths, state, taskStore } = loadProject(root);
945
1012
  const designs = listDesigns(root);
946
1013
  const capabilities = listCapabilities(root);
947
1014
  const errors = [];
948
1015
  const warnings = [];
949
- for (const task of taskStore.tasks) {
950
- for (const ref of task.reads) {
951
- try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
952
- }
1016
+ for (const task of taskStore.tasks) {
1017
+ for (const ref of task.reads) {
1018
+ try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
1019
+ }
953
1020
  if (task.capability_hooks) {
954
- for (const hook of task.capability_hooks) {
955
- const nodeContent = extractCapabilityNode(paths, hook.node);
956
- if (nodeContent === null) warnings.push(`${task.id} references missing capability node: ${hook.node}`);
1021
+ for (const hook of task.capability_hooks) {
1022
+ const nodeContent = extractCapabilityNode(paths, hook.node);
1023
+ if (nodeContent === null) warnings.push(`${task.id} references missing capability node: ${hook.node}`);
957
1024
  }
958
1025
  }
959
1026
  if (task.integrity_version === 1) {
960
- const classificationFindings = taskIntegrityFindings(task, designs, capabilities);
1027
+ const classificationFindings = taskIntegrityFindings(task, designs, capabilities, paths);
961
1028
  const target = ['active', 'done'].includes(task.status) ? errors : warnings;
962
1029
  target.push(...classificationFindings.map((finding) => `${task.id} ${finding}`));
963
1030
  if (task.status === 'done') {
@@ -965,83 +1032,87 @@ export function checkProject(root = findRoot()) {
965
1032
  errors.push(...missingOutputs.map((output) => `${task.id} declared output does not exist: ${output}`));
966
1033
  }
967
1034
  }
968
- const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
969
- const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
970
- if (!hasAcceptance && hasDoneWhen) warnings.push(`${task.id} uses done_when[] without acceptance[] — consider migrating to structured acceptance for clearer verification`);
971
- }
972
- if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
973
- if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
974
- const decisionsContent = readFileSync(paths.decisions, 'utf8');
975
- const affectedMatches = [...decisionsContent.matchAll(/Affected tasks: (.+)/g)];
976
- const allAffected = new Set();
977
- for (const match of affectedMatches) {
978
- for (const taskId of match[1].split(',').map((s) => s.trim()).filter(Boolean)) allAffected.add(taskId);
979
- }
980
- for (const taskId of allAffected) {
981
- const task = taskStore.tasks.find((item) => item.id === taskId);
982
- 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`);
983
- }
1035
+ const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
1036
+ const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
1037
+ if (!hasAcceptance && hasDoneWhen) warnings.push(`${task.id} uses done_when[] without acceptance[] — consider migrating to structured acceptance for clearer verification`);
1038
+ }
1039
+ if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
1040
+ if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
1041
+ const decisionsContent = readFileSync(paths.decisions, 'utf8');
1042
+ for (const block of decisionsContent.split(/\n(?=## )/)) {
1043
+ const affectedMatch = block.match(/- Affected tasks: (.+)/);
1044
+ if (!affectedMatch) continue;
1045
+ const decisionAt = block.match(/- At: (\S+)/)?.[1] || '';
1046
+ for (const taskId of affectedMatch[1].split(',').map((s) => s.trim()).filter(Boolean)) {
1047
+ const task = taskStore.tasks.find((item) => item.id === taskId);
1048
+ if (!task || task.status !== 'done') continue;
1049
+ const completedAt = task.completed_at || task.updated_at || '';
1050
+ if (decisionAt && completedAt && completedAt > decisionAt) continue;
1051
+ warnings.push(`${taskId} is done but was marked affected by a decision; reopen and re-complete it to close this warning (loom task reopen ${taskId} --reason <how the decision was reviewed>)`);
1052
+ }
1053
+ }
984
1054
  if (!capabilities.length) warnings.push('No capability dossier exists; acceptable only when specialist judgment would not change the work');
985
1055
  if (!designs.length) warnings.push('No design document exists; PROJECT.md should remain a concise map of the whole');
986
- if (!existsSync(paths.structure)) warnings.push('No STRUCTURE.md exists; declare where files go so the Agent does not guess');
987
- 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');
1056
+ if (!existsSync(paths.structure)) warnings.push('No STRUCTURE.md exists; declare where files go so the Agent does not guess');
1057
+ 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');
988
1058
  for (const name of designs) {
989
- 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}`);
990
- }
1059
+ 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}`);
1060
+ }
991
1061
  for (const name of capabilities) {
992
- const content = readFileSync(capabilityPath(paths, name), 'utf8');
993
- if (capabilityTemplateResidue(content)) warnings.push(`Capability dossier still contains template instructions: ${name}`);
994
- if (content.includes('### C') && !content.includes('source:')) warnings.push(`Capability dossier has decision tree nodes without source citations: ${name}`);
995
- const statusPath = join(paths.capabilities, name, 'status.json');
996
- if (existsSync(statusPath)) {
997
- const capStatus = readJson(statusPath, 'status.json');
1062
+ const content = readFileSync(capabilityPath(paths, name), 'utf8');
1063
+ if (capabilityTemplateResidue(content)) warnings.push(`Capability dossier still contains template instructions: ${name}`);
1064
+ if (content.includes('### C') && !content.includes('source:')) warnings.push(`Capability dossier has decision tree nodes without source citations: ${name}`);
1065
+ const statusPath = join(paths.capabilities, name, 'status.json');
1066
+ if (existsSync(statusPath)) {
1067
+ const capStatus = readJson(statusPath, 'status.json');
998
1068
  if (capStatus.status && capStatus.status !== 'confirmed') warnings.push(`Capability ${name} is ${capStatus.status}, not confirmed; tasks referencing it proceed provisionally`);
999
1069
  }
1000
1070
  }
1001
1071
  const latestKeeper = state.keeper.attempts.at(-1);
1002
- if (state.keeper.status === 'passed' && latestKeeper && latestKeeper.review?.mode !== 'independent') warnings.push('Keeper pass has no independently attested review provenance; prepare a fresh review before relying on it');
1003
- const coverage = checkDeliverableCoverage(root);
1004
- if (coverage.uncovered > 0) warnings.push(`Uncovered deliverables: ${coverage.uncovered_items.map((item) => item.slug).join(', ')}`);
1005
- return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks), deliverable_coverage: { total: coverage.total, covered: coverage.covered, uncovered: coverage.uncovered } };
1006
- }
1007
-
1008
- export function scaffoldEval(payload, root = findRoot()) {
1009
- if (!payload.id || !payload.title || !payload.brief) throw new Error('Eval scenario requires id, title, and brief');
1010
- const { paths } = loadProject(root);
1011
- const slug = payload.id.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
1012
- const dir = join(paths.eval, slug);
1013
- if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
1014
- mkdirSync(dir, { recursive: true });
1015
- const humanChannel = payload.human_channel || 'available';
1016
- if (!['available', 'unavailable'].includes(humanChannel)) throw new Error('human_channel must be available or unavailable');
1017
- const manifest = {
1018
- schema_version: 1,
1019
- id: payload.id,
1020
- title: payload.title,
1021
- brief: payload.brief,
1022
- primary_comparison: 'same capable Agent without LOOM vs with LOOM',
1023
- hidden_user_facts: payload.hidden_user_facts || [],
1024
- success_criteria: payload.success_criteria || [],
1025
- conditions: [
1026
- { id: 'baseline', framework: 'none', instruction: 'Work normally with all ordinary Agent capabilities and tools.' },
1027
- { id: 'loom', framework: 'loom-v2', instruction: 'Use LOOM as invisible Agent continuity infrastructure.' },
1028
- ],
1029
- controls: {
1030
- same_model_tools_workspace_and_budget: true,
1031
- human_channel: humanChannel,
1032
- minimum_repetitions_per_condition: payload.repetitions || 3,
1033
- scripted_user_answers: true,
1034
- context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
1035
- blind_pairwise_order_swap: true,
1036
- anonymization_preserves_relative_layout: true,
1037
- judge_packet_preflight_required: true,
1038
- condition_output_digest_manifest: true,
1039
- },
1040
- measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
1041
- };
1042
- atomicJson(join(dir, 'manifest.json'), manifest);
1043
- writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false, humanChannel })}\n`, 'utf8');
1044
- writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true, humanChannel })}\n`, 'utf8');
1045
- writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
1046
- return { scenario: payload.id, path: normalizeRef(paths, dir), controls: manifest.controls };
1047
- }
1072
+ if (state.keeper.status === 'passed' && latestKeeper && latestKeeper.review?.mode !== 'independent') errors.push('Keeper pass has no independently attested review provenance; obtain a fresh review (loom review --help) or record loom keeper skip --reason <limitation>');
1073
+ const coverage = checkDeliverableCoverage(root);
1074
+ if (coverage.uncovered > 0) warnings.push(`Uncovered deliverables: ${coverage.uncovered_items.map((item) => item.slug).join(', ')}`);
1075
+ if (coverage.covered > coverage.delivered) warnings.push(`${coverage.covered - coverage.delivered} deliverable(s) are covered only by Tasks not yet done`);
1076
+ return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks), deliverable_coverage: { total: coverage.total, planned: coverage.covered, delivered: coverage.delivered, uncovered: coverage.uncovered } };
1077
+ }
1078
+
1079
+ export function scaffoldEval(payload, root = findRoot()) {
1080
+ if (!payload.id || !payload.title || !payload.brief) throw new Error('Eval scenario requires id, title, and brief');
1081
+ const { paths } = loadProject(root);
1082
+ const slug = payload.id.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
1083
+ const dir = join(paths.eval, slug);
1084
+ if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
1085
+ mkdirSync(dir, { recursive: true });
1086
+ const humanChannel = payload.human_channel || 'available';
1087
+ if (!['available', 'unavailable'].includes(humanChannel)) throw new Error('human_channel must be available or unavailable');
1088
+ const manifest = {
1089
+ schema_version: 1,
1090
+ id: payload.id,
1091
+ title: payload.title,
1092
+ brief: payload.brief,
1093
+ primary_comparison: 'same capable Agent without LOOM vs with LOOM',
1094
+ hidden_user_facts: payload.hidden_user_facts || [],
1095
+ success_criteria: payload.success_criteria || [],
1096
+ conditions: [
1097
+ { id: 'baseline', framework: 'none', instruction: 'Work normally with all ordinary Agent capabilities and tools.' },
1098
+ { id: 'loom', framework: 'loom-v2', instruction: 'Use LOOM as invisible Agent continuity infrastructure.' },
1099
+ ],
1100
+ controls: {
1101
+ same_model_tools_workspace_and_budget: true,
1102
+ human_channel: humanChannel,
1103
+ minimum_repetitions_per_condition: payload.repetitions || 3,
1104
+ scripted_user_answers: true,
1105
+ context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
1106
+ blind_pairwise_order_swap: true,
1107
+ anonymization_preserves_relative_layout: true,
1108
+ judge_packet_preflight_required: true,
1109
+ condition_output_digest_manifest: true,
1110
+ },
1111
+ measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
1112
+ };
1113
+ atomicJson(join(dir, 'manifest.json'), manifest);
1114
+ writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false, humanChannel })}\n`, 'utf8');
1115
+ writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true, humanChannel })}\n`, 'utf8');
1116
+ writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
1117
+ return { scenario: payload.id, path: normalizeRef(paths, dir), controls: manifest.controls };
1118
+ }