@haaaiawd/loom 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -10
- package/EVIL_EVAL.md +22 -9
- package/README.md +40 -19
- package/README.zh-CN.md +27 -12
- package/cli/bin/loom.js +78 -4
- package/cli/src/protocol.js +387 -101
- package/cli/src/store.js +417 -62
- package/design.md +19 -4
- package/docs/PROMPT_CATALOG.md +16 -6
- package/docs/UX_FLOW.md +18 -5
- package/package.json +1 -1
package/cli/src/store.js
CHANGED
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
AGENT_ANCHOR,
|
|
16
|
-
EXECUTION_PROTOCOL,
|
|
14
|
+
agentProtocol,
|
|
15
|
+
AGENT_ANCHOR,
|
|
16
|
+
EXECUTION_PROTOCOL,
|
|
17
17
|
CAPABILITY_TEMPLATE,
|
|
18
18
|
DESIGN_KINDS,
|
|
19
19
|
PROJECT_TEMPLATE,
|
|
20
|
+
RESEARCH_GUIDE,
|
|
21
|
+
STRUCTURE_TEMPLATE,
|
|
20
22
|
designTemplate,
|
|
21
23
|
evalConditionPrompt,
|
|
22
24
|
evalJudgePrompt,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
const SCHEMA_VERSION = 2;
|
|
28
30
|
const VALID_PROJECT_STATUS = new Set(['shaping', 'ready_for_keeper', 'build_ready', 'building', 'complete']);
|
|
29
31
|
const VALID_TASK_STATUS = new Set(['open', 'active', 'blocked', 'done']);
|
|
32
|
+
let runtimePaths = null;
|
|
30
33
|
|
|
31
34
|
function now() {
|
|
32
35
|
return new Date().toISOString();
|
|
@@ -61,6 +64,7 @@ function asObjects(values, key) {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export function findRoot(from = process.cwd()) {
|
|
67
|
+
if (runtimePaths && existsSync(runtimePaths.state)) return runtimePaths.root;
|
|
64
68
|
let cursor = resolve(from);
|
|
65
69
|
while (true) {
|
|
66
70
|
if (existsSync(join(cursor, '.loom', 'state.json'))) return cursor;
|
|
@@ -72,18 +76,24 @@ export function findRoot(from = process.cwd()) {
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export function loomPaths(root = findRoot()) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 };
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
export function initProject(root = process.cwd()) {
|
|
@@ -103,16 +113,18 @@ export function initProject(root = process.cwd()) {
|
|
|
103
113
|
};
|
|
104
114
|
atomicJson(paths.state, state);
|
|
105
115
|
atomicJson(paths.tasks, { schema_version: SCHEMA_VERSION, tasks: [] });
|
|
116
|
+
atomicJson(paths.deliverables, { schema_version: SCHEMA_VERSION, deliverables: [] });
|
|
106
117
|
writeFileSync(paths.project, PROJECT_TEMPLATE, 'utf8');
|
|
118
|
+
writeFileSync(paths.structure, STRUCTURE_TEMPLATE, 'utf8');
|
|
107
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');
|
|
108
|
-
installAgentAnchor(paths.root);
|
|
109
|
-
return { initialized: true, root: paths.root, files: ['.loom/PROJECT.md', '.loom/DECISIONS.md', '.loom/state.json', '.loom/tasks.json', '.loom/design/', '.loom/capabilities/'] };
|
|
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/'] };
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
function loomPathsForInit(root) {
|
|
113
125
|
const absolute = resolve(root);
|
|
114
|
-
const loom = join(absolute, '.loom');
|
|
115
|
-
return
|
|
126
|
+
const loom = runtimePaths?.root === absolute ? runtimePaths.loom : join(absolute, '.loom');
|
|
127
|
+
return loomPathsShape(absolute, loom);
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
function loomPathsShape(root, loom) {
|
|
@@ -121,7 +133,9 @@ function loomPathsShape(root, loom) {
|
|
|
121
133
|
loom,
|
|
122
134
|
state: join(loom, 'state.json'),
|
|
123
135
|
tasks: join(loom, 'tasks.json'),
|
|
136
|
+
deliverables: join(loom, 'deliverables.json'),
|
|
124
137
|
project: join(loom, 'PROJECT.md'),
|
|
138
|
+
structure: join(loom, 'STRUCTURE.md'),
|
|
125
139
|
decisions: join(loom, 'DECISIONS.md'),
|
|
126
140
|
design: join(loom, 'design'),
|
|
127
141
|
capabilities: join(loom, 'capabilities'),
|
|
@@ -143,9 +157,10 @@ export function loadProject(root = findRoot()) {
|
|
|
143
157
|
mkdirSync(paths.eval, { recursive: true });
|
|
144
158
|
const state = readJson(paths.state, 'state.json');
|
|
145
159
|
const taskStore = readJson(paths.tasks, 'tasks.json');
|
|
160
|
+
const deliverableStore = existsSync(paths.deliverables) ? readJson(paths.deliverables, 'deliverables.json') : { schema_version: SCHEMA_VERSION, deliverables: [] };
|
|
146
161
|
validateState(state);
|
|
147
162
|
validateTasks(taskStore.tasks);
|
|
148
|
-
return { paths, state, taskStore };
|
|
163
|
+
return { paths, state, taskStore, deliverableStore };
|
|
149
164
|
}
|
|
150
165
|
|
|
151
166
|
function validateState(state) {
|
|
@@ -164,9 +179,36 @@ function validateTasks(tasks) {
|
|
|
164
179
|
if (!task.id || ids.has(task.id)) throw new Error(`Task id is missing or duplicated: ${task.id || '<missing>'}`);
|
|
165
180
|
ids.add(task.id);
|
|
166
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`);
|
|
167
183
|
if (!VALID_TASK_STATUS.has(task.status)) throw new Error(`${task.id} has invalid status ${task.status}`);
|
|
168
|
-
if (!Array.isArray(task.done_when) || !task.done_when.length) throw new Error(`${task.id} requires done_when`);
|
|
169
184
|
if (!Array.isArray(task.depends_on) || !Array.isArray(task.reads)) throw new Error(`${task.id} dependencies and reads must be arrays`);
|
|
185
|
+
if (!task.reads.length) throw new Error(`${task.id} reads must list at least one specific file or artifact`);
|
|
186
|
+
if (!Array.isArray(task.touches) || !task.touches.length) throw new Error(`${task.id} touches must list at least one specific file or artifact`);
|
|
187
|
+
if (!Array.isArray(task.boundaries) || !task.boundaries.length) throw new Error(`${task.id} boundaries must list at least one thing this Task does NOT do`);
|
|
188
|
+
const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
|
|
189
|
+
const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
|
|
190
|
+
if (!hasAcceptance && !hasDoneWhen) throw new Error(`${task.id} requires acceptance[] (preferred) or done_when[]`);
|
|
191
|
+
if (hasAcceptance) {
|
|
192
|
+
for (const acc of task.acceptance) {
|
|
193
|
+
if (!acc || typeof acc !== 'object') throw new Error(`${task.id} acceptance entries must be objects`);
|
|
194
|
+
if (!acc.criterion || typeof acc.criterion !== 'string') throw new Error(`${task.id} acceptance entry missing criterion (what must be true for this condition to pass)`);
|
|
195
|
+
if (!acc.verify_by || typeof acc.verify_by !== 'string') throw new Error(`${task.id} acceptance entry missing verify_by (how to check)`);
|
|
196
|
+
if (acc.evidence !== undefined && typeof acc.evidence !== 'string') throw new Error(`${task.id} acceptance evidence must be a string`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (task.implements !== undefined && typeof task.implements !== 'string') throw new Error(`${task.id} implements must be a string referencing a design decision`);
|
|
200
|
+
if (task.capability_hooks !== undefined) {
|
|
201
|
+
if (!Array.isArray(task.capability_hooks)) throw new Error(`${task.id} capability_hooks must be an array`);
|
|
202
|
+
for (const hook of task.capability_hooks) {
|
|
203
|
+
if (!hook || typeof hook !== 'object' || typeof hook.node !== 'string' || !hook.node.includes('#')) throw new Error(`${task.id} capability_hooks entry must have a node field like "capability-slug#C1"`);
|
|
204
|
+
if (hook.at !== undefined && typeof hook.at !== 'string') throw new Error(`${task.id} capability_hooks at must be a string`);
|
|
205
|
+
if (hook.must_produce !== undefined && typeof hook.must_produce !== 'string') throw new Error(`${task.id} capability_hooks must_produce must be a string`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (task.covers !== undefined) {
|
|
209
|
+
if (!Array.isArray(task.covers)) throw new Error(`${task.id} covers must be an array of deliverable IDs`);
|
|
210
|
+
for (const dlvId of task.covers) if (typeof dlvId !== 'string') throw new Error(`${task.id} covers entries must be deliverable ID strings`);
|
|
211
|
+
}
|
|
170
212
|
if (task.status === 'active') active += 1;
|
|
171
213
|
}
|
|
172
214
|
if (active > 1) throw new Error('Only one Task may be active');
|
|
@@ -262,24 +304,216 @@ export function createCapability(slug, options = {}, root = findRoot()) {
|
|
|
262
304
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
|
|
263
305
|
if (!options.title) throw new Error('Capability requires --title');
|
|
264
306
|
const { paths } = loadProject(root);
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
307
|
+
const dirPath = join(paths.capabilities, slug);
|
|
308
|
+
const legacyPath = join(paths.capabilities, `${slug}.md`);
|
|
309
|
+
if (existsSync(dirPath) || existsSync(legacyPath)) throw new Error(`Capability already exists: ${slug}`);
|
|
310
|
+
mkdirSync(join(dirPath, 'research'), { recursive: true });
|
|
311
|
+
const capabilityPath = join(dirPath, 'capability.md');
|
|
312
|
+
writeFileSync(capabilityPath, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
|
|
313
|
+
atomicJson(join(dirPath, 'status.json'), { status: 'researching', title: options.title, field: options.field || '', scenario: '', confirmed_at: '', created_at: now() });
|
|
314
|
+
return { slug, path: relative(paths.root, capabilityPath).replaceAll('\\', '/') };
|
|
269
315
|
}
|
|
270
316
|
|
|
271
317
|
export function listCapabilities(root = findRoot()) {
|
|
272
318
|
const { paths } = loadProject(root);
|
|
273
|
-
|
|
319
|
+
const entries = readdirSync(paths.capabilities, { withFileTypes: true });
|
|
320
|
+
const names = entries
|
|
321
|
+
.filter((entry) => (entry.isDirectory() && existsSync(join(paths.capabilities, entry.name, 'capability.md'))) || (entry.isFile() && entry.name.endsWith('.md')))
|
|
322
|
+
.map((entry) => (entry.isDirectory() ? entry.name : entry.name))
|
|
323
|
+
.sort();
|
|
324
|
+
return names;
|
|
274
325
|
}
|
|
275
326
|
|
|
276
327
|
export function getCapability(slug, root = findRoot()) {
|
|
277
328
|
const { paths } = loadProject(root);
|
|
278
|
-
const
|
|
279
|
-
if (basename(
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
return readFileSync(
|
|
329
|
+
const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug;
|
|
330
|
+
if (basename(cleanSlug) !== cleanSlug) throw new Error('Invalid capability name');
|
|
331
|
+
const dirPath = join(paths.capabilities, cleanSlug);
|
|
332
|
+
const dirCapability = join(dirPath, 'capability.md');
|
|
333
|
+
if (existsSync(dirCapability)) return readFileSync(dirCapability, 'utf8');
|
|
334
|
+
const legacyPath = join(paths.capabilities, `${cleanSlug}.md`);
|
|
335
|
+
if (existsSync(legacyPath)) return readFileSync(legacyPath, 'utf8');
|
|
336
|
+
throw new Error(`Capability not found: ${slug}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function researchCapability(slug, options = {}, root = findRoot()) {
|
|
340
|
+
if (!options.field) throw new Error('Research requires --field <professional-field>');
|
|
341
|
+
const { paths } = loadProject(root);
|
|
342
|
+
const dir = join(paths.capabilities, slug);
|
|
343
|
+
if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}. Run loom capability add ${slug} --title <text> first.`);
|
|
344
|
+
const statusPath = join(dir, 'status.json');
|
|
345
|
+
if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
|
|
346
|
+
const status = readJson(statusPath, 'status.json');
|
|
347
|
+
if (status.status === 'confirmed') {
|
|
348
|
+
status.reopened_at = now();
|
|
349
|
+
status.reopen_reason = options.reopen_reason || 'new evidence requires updating the capability';
|
|
350
|
+
}
|
|
351
|
+
status.field = options.field;
|
|
352
|
+
status.status = 'researching';
|
|
353
|
+
status.updated_at = now();
|
|
354
|
+
atomicJson(statusPath, status);
|
|
355
|
+
const researchDir = join(dir, 'research');
|
|
356
|
+
mkdirSync(researchDir, { recursive: true });
|
|
357
|
+
const guidePath = join(researchDir, '_guide.md');
|
|
358
|
+
if (!existsSync(guidePath)) writeFileSync(guidePath, RESEARCH_GUIDE, 'utf8');
|
|
359
|
+
return {
|
|
360
|
+
slug,
|
|
361
|
+
field: options.field,
|
|
362
|
+
research_dir: relative(paths.root, researchDir).replaceAll('\\', '/'),
|
|
363
|
+
status: 'researching',
|
|
364
|
+
next: `Add .md files to research/ — one per expert narrative, case study, or methodology source. See research/_guide.md for what to write. Then run loom capability synthesize ${slug}.`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function synthesizeCapability(slug, root = findRoot()) {
|
|
369
|
+
const { paths } = loadProject(root);
|
|
370
|
+
const dir = join(paths.capabilities, slug);
|
|
371
|
+
if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
|
|
372
|
+
const statusPath = join(dir, 'status.json');
|
|
373
|
+
if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
|
|
374
|
+
const status = readJson(statusPath, 'status.json');
|
|
375
|
+
if (status.status === 'confirmed') throw new Error(`Capability ${slug} is confirmed; run loom capability research ${slug} --field <text> to reopen it with new evidence`);
|
|
376
|
+
const researchDir = join(dir, 'research');
|
|
377
|
+
const materials = readdirSync(researchDir).filter((f) => f.endsWith('.md') && f !== '_guide.md').sort();
|
|
378
|
+
if (!materials.length) throw new Error(`No research materials found in ${slug}/research/. Add .md files (one per expert narrative, case study, or methodology source). See research/_guide.md for guidance. Then run loom capability synthesize ${slug}.`);
|
|
379
|
+
const capabilityPath = join(dir, 'capability.md');
|
|
380
|
+
const content = readFileSync(capabilityPath, 'utf8');
|
|
381
|
+
const nodePattern = /### (C\d+):/g;
|
|
382
|
+
const nodes = [...content.matchAll(nodePattern)].map((m) => m[1]);
|
|
383
|
+
if (!nodes.length) throw new Error('Capability has no decision tree nodes (### C1, C2, ...). Add nodes before synthesizing.');
|
|
384
|
+
const missingSources = [];
|
|
385
|
+
const missingCounterexamples = [];
|
|
386
|
+
const danglingSources = [];
|
|
387
|
+
const materialNames = materials.flatMap((f) => [f, f.slice(0, -3)]);
|
|
388
|
+
for (const node of nodes) {
|
|
389
|
+
const nodeSection = content.split(`### ${node}:`)[1]?.split('### ')[0] || '';
|
|
390
|
+
if (!nodeSection.includes('source:')) missingSources.push(node);
|
|
391
|
+
else {
|
|
392
|
+
const sourceMatch = nodeSection.match(/source:\s*(.+)(?:\n|$)/);
|
|
393
|
+
const cited = sourceMatch ? sourceMatch[1].trim() : '';
|
|
394
|
+
if (cited && !materialNames.some((name) => cited.includes(name))) danglingSources.push(node);
|
|
395
|
+
}
|
|
396
|
+
if (!nodeSection.includes('counterexample:')) missingCounterexamples.push(node);
|
|
397
|
+
}
|
|
398
|
+
if (missingSources.length) throw new Error(`Decision tree nodes missing source citations: ${missingSources.join(', ')}. Every node must reference a research material.`);
|
|
399
|
+
if (danglingSources.length) throw new Error(`Decision tree nodes cite sources not found in research/: ${danglingSources.join(', ')}. The source field should reference one of the research files.`);
|
|
400
|
+
if (missingCounterexamples.length) throw new Error(`Decision tree nodes missing counterexamples: ${missingCounterexamples.join(', ')}. Every node must have a counterexample (a situation where an expert would NOT walk this path).`);
|
|
401
|
+
status.status = 'synthesized';
|
|
402
|
+
status.updated_at = now();
|
|
403
|
+
atomicJson(statusPath, status);
|
|
404
|
+
return { slug, status: 'synthesized', nodes: nodes.length, materials: materials.length };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function confirmCapability(slug, options = {}, root = findRoot()) {
|
|
408
|
+
if (!options.scenario || options.scenario.length < 20) throw new Error('Confirm requires --scenario <text> (at least 20 characters describing which expert situation this project most resembles)');
|
|
409
|
+
const { paths } = loadProject(root);
|
|
410
|
+
const dir = join(paths.capabilities, slug);
|
|
411
|
+
if (!existsSync(dir)) throw new Error(`Capability not found: ${slug}`);
|
|
412
|
+
const statusPath = join(dir, 'status.json');
|
|
413
|
+
if (!existsSync(statusPath)) throw new Error(`Capability ${slug} is not a research-directory dossier`);
|
|
414
|
+
const status = readJson(statusPath, 'status.json');
|
|
415
|
+
if (status.status !== 'synthesized') throw new Error(`Capability ${slug} must be synthesized before confirmation. Run loom capability synthesize ${slug} first.`);
|
|
416
|
+
status.scenario = options.scenario;
|
|
417
|
+
status.status = 'confirmed';
|
|
418
|
+
status.confirmed_at = now();
|
|
419
|
+
status.updated_at = now();
|
|
420
|
+
atomicJson(statusPath, status);
|
|
421
|
+
const capabilityPath = join(dir, 'capability.md');
|
|
422
|
+
const content = readFileSync(capabilityPath, 'utf8');
|
|
423
|
+
const scenarioMatch = content.match(/(## Project scenario\n)([\s\S]*?)(\n## )/);
|
|
424
|
+
if (scenarioMatch) {
|
|
425
|
+
const prefix = scenarioMatch[1];
|
|
426
|
+
const suffix = scenarioMatch[3];
|
|
427
|
+
const blockquote = scenarioMatch[2].match(/(> [^\n]+\n)+/);
|
|
428
|
+
const blockquoteText = blockquote ? blockquote[0] : '';
|
|
429
|
+
const updated = content.replace(/## Project scenario\n[\s\S]*?\n## /, `${prefix}${blockquoteText}\n${options.scenario}\n${suffix}`);
|
|
430
|
+
writeFileSync(capabilityPath, updated, 'utf8');
|
|
431
|
+
}
|
|
432
|
+
return { slug, status: 'confirmed', scenario: options.scenario };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function getCapabilityStatus(slug, root = findRoot()) {
|
|
436
|
+
const { paths } = loadProject(root);
|
|
437
|
+
const dir = join(paths.capabilities, slug);
|
|
438
|
+
const statusPath = join(dir, 'status.json');
|
|
439
|
+
if (!existsSync(statusPath)) return { slug, status: 'legacy' };
|
|
440
|
+
return { ...readJson(statusPath, 'status.json'), slug };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function addDeliverable(slug, options = {}, root = findRoot()) {
|
|
444
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Deliverable slug must use lowercase letters, numbers, and hyphens');
|
|
445
|
+
if (!options.title) throw new Error('Deliverable requires --title');
|
|
446
|
+
if (!options.kind) throw new Error('Deliverable requires --kind (module, feature, behavior, interface, artifact, operational, verification, or other)');
|
|
447
|
+
const { paths, deliverableStore } = loadProject(root);
|
|
448
|
+
if (deliverableStore.deliverables.some((item) => item.slug === slug)) throw new Error(`Deliverable already exists: ${slug}`);
|
|
449
|
+
const id = nextId(deliverableStore.deliverables, 'DLV');
|
|
450
|
+
deliverableStore.deliverables.push({ id, slug, title: options.title, kind: options.kind, notes: options.notes || '', covered_by: [], created_at: now() });
|
|
451
|
+
atomicJson(paths.deliverables, deliverableStore);
|
|
452
|
+
return { id, slug, title: options.title, kind: options.kind };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function listDeliverables(root = findRoot()) {
|
|
456
|
+
const { deliverableStore } = loadProject(root);
|
|
457
|
+
return deliverableStore.deliverables.map((item) => ({ id: item.id, slug: item.slug, title: item.title, kind: item.kind, covered: item.covered_by.length > 0 }));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function checkDeliverableCoverage(root = findRoot()) {
|
|
461
|
+
const { paths, taskStore, deliverableStore } = loadProject(root);
|
|
462
|
+
const taskCovers = new Map();
|
|
463
|
+
for (const task of taskStore.tasks) {
|
|
464
|
+
for (const dlvId of task.covers || []) {
|
|
465
|
+
if (!taskCovers.has(dlvId)) taskCovers.set(dlvId, []);
|
|
466
|
+
taskCovers.get(dlvId).push(task.id);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const uncovered = [];
|
|
470
|
+
const covered = [];
|
|
471
|
+
for (const dlv of deliverableStore.deliverables) {
|
|
472
|
+
const tasks = taskCovers.get(dlv.id) || [];
|
|
473
|
+
if (tasks.length === 0) uncovered.push({ id: dlv.id, slug: dlv.slug, title: dlv.title });
|
|
474
|
+
else covered.push({ id: dlv.id, slug: dlv.slug, tasks });
|
|
475
|
+
}
|
|
476
|
+
let changed = false;
|
|
477
|
+
for (const dlv of deliverableStore.deliverables) {
|
|
478
|
+
const tasks = taskCovers.get(dlv.id) || [];
|
|
479
|
+
const sorted = [...tasks].sort();
|
|
480
|
+
const current = JSON.stringify(dlv.covered_by);
|
|
481
|
+
const newVal = JSON.stringify(sorted);
|
|
482
|
+
if (current !== newVal) { dlv.covered_by = sorted; changed = true; }
|
|
483
|
+
}
|
|
484
|
+
if (changed) atomicJson(paths.deliverables, deliverableStore);
|
|
485
|
+
return { total: deliverableStore.deliverables.length, covered: covered.length, uncovered: uncovered.length, uncovered_items: uncovered, covered_items: covered };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function capabilityPath(paths, name) {
|
|
489
|
+
const dirCapability = join(paths.capabilities, name, 'capability.md');
|
|
490
|
+
if (existsSync(dirCapability)) return dirCapability;
|
|
491
|
+
return join(paths.capabilities, name);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function capabilityTemplateResidue(content) {
|
|
495
|
+
return content.includes('Name the established field, what expertise it contributes')
|
|
496
|
+
|| content.includes('<node name>')
|
|
497
|
+
|| content.includes('<which research material or expert narrative supports this node>');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function extractCapabilityNode(paths, nodeRef) {
|
|
501
|
+
const parts = nodeRef.split('#');
|
|
502
|
+
if (parts.length !== 2) return null;
|
|
503
|
+
const [slug, nodeId] = parts;
|
|
504
|
+
const capPath = capabilityPath(paths, slug);
|
|
505
|
+
if (!existsSync(capPath)) return null;
|
|
506
|
+
const content = readFileSync(capPath, 'utf8');
|
|
507
|
+
const header = `### ${nodeId}:`;
|
|
508
|
+
const headerIndex = content.indexOf(header);
|
|
509
|
+
if (headerIndex === -1) return null;
|
|
510
|
+
const afterHeader = content.slice(headerIndex + header.length);
|
|
511
|
+
const nextNode = afterHeader.search(/### C\d+:/);
|
|
512
|
+
const nextSection = afterHeader.search(/\n## /);
|
|
513
|
+
let endIdx = afterHeader.length;
|
|
514
|
+
if (nextNode !== -1) endIdx = Math.min(endIdx, nextNode);
|
|
515
|
+
if (nextSection !== -1) endIdx = Math.min(endIdx, nextSection);
|
|
516
|
+
return `${header}${afterHeader.slice(0, endIdx)}`;
|
|
283
517
|
}
|
|
284
518
|
|
|
285
519
|
export function importTasks(payload, root = findRoot()) {
|
|
@@ -294,11 +528,15 @@ export function importTasks(payload, root = findRoot()) {
|
|
|
294
528
|
id,
|
|
295
529
|
title: raw.title,
|
|
296
530
|
outcome: raw.outcome,
|
|
531
|
+
acceptance: raw.acceptance || [],
|
|
297
532
|
done_when: raw.done_when || [],
|
|
298
533
|
boundaries: raw.boundaries || [],
|
|
299
534
|
depends_on: raw.depends_on || [],
|
|
300
535
|
reads: raw.reads || ['.loom/PROJECT.md'],
|
|
301
536
|
touches: raw.touches || [],
|
|
537
|
+
implements: raw.implements || '',
|
|
538
|
+
capability_hooks: raw.capability_hooks || [],
|
|
539
|
+
covers: raw.covers || [],
|
|
302
540
|
status: 'open',
|
|
303
541
|
progress: raw.progress || { completed: [], current: '', next: '' },
|
|
304
542
|
evidence: raw.evidence || [],
|
|
@@ -339,7 +577,7 @@ export function updateTask(id, patch, root = findRoot()) {
|
|
|
339
577
|
const { paths, taskStore } = loadProject(root);
|
|
340
578
|
const task = taskStore.tasks.find((item) => item.id === id);
|
|
341
579
|
if (!task) throw new Error(`Task not found: ${id}`);
|
|
342
|
-
const allowed = ['title', 'outcome', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'progress', 'evidence'];
|
|
580
|
+
const allowed = ['title', 'outcome', 'acceptance', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'implements', 'capability_hooks', 'covers', 'progress', 'evidence'];
|
|
343
581
|
for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
|
|
344
582
|
Object.assign(task, patch, { updated_at: now() });
|
|
345
583
|
validateTasks(taskStore.tasks);
|
|
@@ -416,19 +654,40 @@ export function completeTask(id, payload, root = findRoot()) {
|
|
|
416
654
|
if (!task) throw new Error(`Task not found: ${id}`);
|
|
417
655
|
if (task.status !== 'active') throw new Error(`${id} is not active`);
|
|
418
656
|
if (!Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Completing a Task requires concrete evidence');
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
657
|
+
const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
|
|
658
|
+
if (hasAcceptance) {
|
|
659
|
+
if (!Array.isArray(payload.acceptance_results)) throw new Error('Completing a Task with acceptance[] requires acceptance_results[]');
|
|
660
|
+
const criteria = new Set(task.acceptance.map((acc) => acc.criterion));
|
|
661
|
+
const seen = new Set();
|
|
662
|
+
for (const result of payload.acceptance_results) {
|
|
663
|
+
if (!result || !criteria.has(result.criterion)) throw new Error('acceptance_results criterion must match an exact acceptance criterion');
|
|
664
|
+
if (seen.has(result.criterion)) throw new Error(`Duplicate acceptance result: ${result.criterion}`);
|
|
665
|
+
if (!result.evidence || typeof result.evidence !== 'string' || result.evidence.length < 5) throw new Error(`acceptance result requires concrete evidence: ${result.criterion}`);
|
|
666
|
+
seen.add(result.criterion);
|
|
667
|
+
}
|
|
668
|
+
const missingResults = task.acceptance.filter((acc) => !seen.has(acc.criterion));
|
|
669
|
+
if (missingResults.length) throw new Error(`Task completion is missing acceptance results:\n- ${missingResults.map((acc) => acc.criterion).join('\n- ')}`);
|
|
670
|
+
for (const result of payload.acceptance_results) {
|
|
671
|
+
const acc = task.acceptance.find((acc) => acc.criterion === result.criterion);
|
|
672
|
+
acc.evidence = result.evidence;
|
|
673
|
+
}
|
|
674
|
+
task.status = 'done';
|
|
675
|
+
task.evidence = [...task.evidence, ...payload.evidence, { type: 'acceptance_results', results: payload.acceptance_results, at: now() }];
|
|
676
|
+
} else {
|
|
677
|
+
if (!Array.isArray(payload.checks)) throw new Error('Completing a Task with done_when[] requires checks for every done_when criterion');
|
|
678
|
+
const criteria = new Set(task.done_when);
|
|
679
|
+
const seen = new Set();
|
|
680
|
+
for (const check of payload.checks) {
|
|
681
|
+
if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
|
|
682
|
+
if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
|
|
683
|
+
if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
|
|
684
|
+
seen.add(check.criterion);
|
|
685
|
+
}
|
|
686
|
+
const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
|
|
687
|
+
if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
|
|
688
|
+
task.status = 'done';
|
|
689
|
+
task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
|
|
690
|
+
}
|
|
432
691
|
task.progress = { ...task.progress, current: 'complete', next: '' };
|
|
433
692
|
task.updated_at = now();
|
|
434
693
|
validateTasks(taskStore.tasks);
|
|
@@ -452,7 +711,7 @@ export function markReady(root = findRoot()) {
|
|
|
452
711
|
if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
|
|
453
712
|
const unfinishedDesigns = designs.filter((name) => readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.'));
|
|
454
713
|
if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
|
|
455
|
-
const unfinishedCapabilities = capabilities.filter((name) => readFileSync(
|
|
714
|
+
const unfinishedCapabilities = capabilities.filter((name) => capabilityTemplateResidue(readFileSync(capabilityPath(paths, name), 'utf8')));
|
|
456
715
|
if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
|
|
457
716
|
if (!taskStore.tasks.length) errors.push('The initial work map is empty');
|
|
458
717
|
if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
|
|
@@ -462,6 +721,14 @@ export function markReady(root = findRoot()) {
|
|
|
462
721
|
errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
|
|
463
722
|
}
|
|
464
723
|
if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
|
|
724
|
+
if (latestKeeper?.can_auto_pass && latestKeeper.prepared_digest !== digest) {
|
|
725
|
+
state.keeper.status = 'passed';
|
|
726
|
+
state.project.status = 'build_ready';
|
|
727
|
+
state.project.updated_at = now();
|
|
728
|
+
state.keeper.auto_passed = true;
|
|
729
|
+
atomicJson(paths.state, state);
|
|
730
|
+
return { ready_for_keeper: false, auto_passed: true, next: 'Keeper minor gaps fixed; auto-passed without a new Keeper round' };
|
|
731
|
+
}
|
|
465
732
|
state.project.status = 'ready_for_keeper';
|
|
466
733
|
state.project.updated_at = now();
|
|
467
734
|
state.keeper.prepared_digest = digest;
|
|
@@ -475,7 +742,11 @@ function projectDigest(paths, tasks) {
|
|
|
475
742
|
hash.update(readFileSync(paths.project));
|
|
476
743
|
hash.update(readFileSync(paths.decisions));
|
|
477
744
|
for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
|
|
478
|
-
for (const name of
|
|
745
|
+
for (const name of listCapabilities()) {
|
|
746
|
+
hash.update(readFileSync(capabilityPath(paths, name), 'utf8'));
|
|
747
|
+
const statusPath = join(paths.capabilities, name, 'status.json');
|
|
748
|
+
if (existsSync(statusPath)) hash.update(readFileSync(statusPath, 'utf8'));
|
|
749
|
+
}
|
|
479
750
|
hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
|
|
480
751
|
return hash.digest('hex');
|
|
481
752
|
}
|
|
@@ -498,12 +769,22 @@ export function recordKeeper(payload, root = findRoot()) {
|
|
|
498
769
|
if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
|
|
499
770
|
throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
|
|
500
771
|
}
|
|
772
|
+
const blockingGaps = (payload.gaps || []).filter((gap) => {
|
|
773
|
+
if (typeof gap === 'string') return true;
|
|
774
|
+
if (typeof gap === 'object' && gap.severity !== 'minor') return true;
|
|
775
|
+
return false;
|
|
776
|
+
});
|
|
777
|
+
const minorGaps = (payload.gaps || []).filter((gap) => {
|
|
778
|
+
if (typeof gap === 'object' && gap.severity === 'minor') return true;
|
|
779
|
+
return false;
|
|
780
|
+
});
|
|
781
|
+
const canAutoPass = payload.verdict === 'needs_revision' && blockingGaps.length === 0 && minorGaps.length > 0 && minorGaps.length <= 3;
|
|
501
782
|
if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
|
|
502
783
|
if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
|
|
503
784
|
if (!payload.prepared_digest || payload.prepared_digest !== state.keeper.prepared_digest) throw new Error('Keeper result prepared_digest does not match the current ready state');
|
|
504
785
|
const currentDigest = projectDigest(paths, taskStore.tasks);
|
|
505
786
|
if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
|
|
506
|
-
const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], at: now() };
|
|
787
|
+
const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], can_auto_pass: canAutoPass, at: now() };
|
|
507
788
|
state.keeper.attempts.push(attempt);
|
|
508
789
|
state.keeper.status = payload.verdict;
|
|
509
790
|
if (payload.verdict === 'passed') state.project.status = 'build_ready';
|
|
@@ -525,27 +806,63 @@ export function skipKeeper(reason, root = findRoot()) {
|
|
|
525
806
|
return state.keeper;
|
|
526
807
|
}
|
|
527
808
|
|
|
809
|
+
export function recordDecision(payload, root = findRoot()) {
|
|
810
|
+
if (!payload || !payload.summary || payload.summary.length < 10) throw new Error('Decision requires --summary (what changed and why)');
|
|
811
|
+
if (!payload.changes || !Array.isArray(payload.changes) || !payload.changes.length) throw new Error('Decision requires --changes (array of affected files or decisions)');
|
|
812
|
+
const { paths } = loadProject(root);
|
|
813
|
+
const id = `D-${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
|
|
814
|
+
const entry = `## ${id}: ${payload.summary}\n\n- Changed: ${payload.changes.join(', ')}\n${payload.affected_tasks ? `- Affected tasks: ${payload.affected_tasks.join(', ')}\n` : ''}- At: ${now()}\n`;
|
|
815
|
+
const existing = readFileSync(paths.decisions, 'utf8');
|
|
816
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
817
|
+
writeFileSync(paths.decisions, existing + separator + entry, 'utf8');
|
|
818
|
+
return { id, summary: payload.summary, changes: payload.changes, affected_tasks: payload.affected_tasks || [] };
|
|
819
|
+
}
|
|
820
|
+
|
|
528
821
|
export function compileContext(options = {}, root = findRoot()) {
|
|
529
822
|
const { paths, state, taskStore } = loadProject(root);
|
|
530
823
|
const designs = listDesigns(root);
|
|
531
824
|
const capabilities = listCapabilities(root);
|
|
532
825
|
const summary = taskSummary(taskStore.tasks);
|
|
533
826
|
const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
|
|
534
|
-
const
|
|
535
|
-
|
|
827
|
+
const next = nextTask(taskStore.tasks);
|
|
828
|
+
const recommendation = task
|
|
829
|
+
? `You have an active Task: ${task.id}. Read the Active Task, its reads, and the Capability decision points below. Then take the smallest action that advances the outcome inside the boundaries. Update progress or mark done only with concrete evidence.`
|
|
830
|
+
: next
|
|
831
|
+
? `No active Task. The next executable Task is ${next.id}. Start it with \`loom task start ${next.id}\` if the project is build_ready, or run \`loom project ready\` if not. If the next Task is wrong, repair the Work Map first.`
|
|
832
|
+
: state.project.status === 'complete'
|
|
833
|
+
? 'All Tasks are done. Run \`loom check\` to verify health. If new work arises, record the decision and update the Work Map.'
|
|
834
|
+
: state.project.status === 'shaping'
|
|
835
|
+
? 'Project is still shaping. Confirm the intended result, identify open questions, and build the Work Map before starting material work.'
|
|
836
|
+
: 'No executable Task. Create or update Tasks so the Work Map matches the project goal.';
|
|
837
|
+
const statusBlock = `## Current LOOM state and recommended action\n\n- Project status: ${state.project.status}\n- Active task: ${summary.active || 'none'}\n- Work map: ${summary.total} total, ${summary.open} open, ${summary.done} done, ${summary.blocked} blocked\n- Design documents: ${designs.length}\n- Capability dossiers: ${capabilities.length}\n- Keeper status: ${state.keeper.status}\n\n**Recommended next action:** ${recommendation}\n\nThis is a recommendation, not a script. Use your judgment; if you choose differently, record the reason in \`.loom/DECISIONS.md\` or the active Task evidence.`;
|
|
838
|
+
const blocks = [statusBlock, agentProtocol({ humanChannel: options.humanChannel || 'available' }), shapingContext({ state, taskSummary: summary, capabilityNames: capabilities, designNames: designs, forKeeper: Boolean(options.keeper) })];
|
|
839
|
+
blocks.push(`## Project whole (${normalizeRef(paths, paths.project)})\n\n${readFileSync(paths.project, 'utf8')}`);
|
|
840
|
+
if (existsSync(paths.structure)) blocks.push(`## Project structure (${normalizeRef(paths, paths.structure)})\n\n${readFileSync(paths.structure, 'utf8')}`);
|
|
536
841
|
if (options.keeper) {
|
|
537
842
|
blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
|
|
538
|
-
blocks.push(`## Decision history (${
|
|
843
|
+
blocks.push(`## Decision history (${normalizeRef(paths, paths.decisions)})\n\n${readFileSync(paths.decisions, 'utf8')}`);
|
|
539
844
|
blocks.push(`## Work map summary\n\n${JSON.stringify(summary, null, 2)}\n\nFirst executable Task:\n\n${JSON.stringify(nextTask(taskStore.tasks), null, 2)}`);
|
|
540
845
|
for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
|
|
541
846
|
for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
|
|
542
|
-
} else if (task) {
|
|
543
|
-
blocks.push(EXECUTION_PROTOCOL);
|
|
544
|
-
blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
|
|
847
|
+
} else if (task) {
|
|
848
|
+
blocks.push(EXECUTION_PROTOCOL);
|
|
849
|
+
blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
|
|
545
850
|
for (const ref of task.reads) {
|
|
546
851
|
const content = readContextDocument(paths, ref);
|
|
547
852
|
if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
|
|
548
853
|
}
|
|
854
|
+
if (task.capability_hooks && task.capability_hooks.length) {
|
|
855
|
+
const hookBlocks = [];
|
|
856
|
+
for (const hook of task.capability_hooks) {
|
|
857
|
+
const nodeContent = extractCapabilityNode(paths, hook.node);
|
|
858
|
+
if (nodeContent) {
|
|
859
|
+
const atLine = hook.at ? `\n\n**Activate at:** ${hook.at}` : '';
|
|
860
|
+
const produceLine = hook.must_produce ? `\n\n**Must produce:** ${hook.must_produce}` : '';
|
|
861
|
+
hookBlocks.push(`### Capability hook: ${hook.node}${atLine}${produceLine}\n\n${nodeContent}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (hookBlocks.length) blocks.push(`## Capability decision points\n\nYou are at specific decision-tree nodes from professional capability dossiers. Use them to inform your judgment — each node carries options, criteria, a source, and a counterexample. If the evidence points somewhere the tree does not cover, trust the evidence and update the capability.\n\n${hookBlocks.join('\n\n')}`);
|
|
865
|
+
}
|
|
549
866
|
} else {
|
|
550
867
|
blocks.push(`## On-demand project context\n\n- Decision history: .loom/DECISIONS.md (read when correction or lineage matters)\n${designs.length ? designs.map((name) => `- Design: .loom/design/${name}`).join('\n') : '- No design documents yet; split the whole according to consequential systems and decisions.'}\n${capabilities.length ? capabilities.map((name) => `- Professional capability: .loom/capabilities/${name}`).join('\n') : '- No professional capability dossiers yet; create separate field dossiers only where expertise changes the work.'}`);
|
|
551
868
|
}
|
|
@@ -553,15 +870,20 @@ export function compileContext(options = {}, root = findRoot()) {
|
|
|
553
870
|
}
|
|
554
871
|
|
|
555
872
|
function normalizeRef(paths, absolute) {
|
|
873
|
+
const loomPrefix = `${paths.loom}${process.platform === 'win32' ? '\\' : '/'}`;
|
|
874
|
+
if (absolute === paths.loom || absolute.startsWith(loomPrefix)) return `.loom/${relative(paths.loom, absolute).replaceAll('\\', '/')}`.replace(/\/$/, '');
|
|
556
875
|
return relative(paths.root, absolute).replaceAll('\\', '/');
|
|
557
876
|
}
|
|
558
877
|
|
|
559
878
|
function readContextDocument(paths, ref) {
|
|
560
879
|
const normalized = ref.replaceAll('\\', '/');
|
|
561
880
|
if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
881
|
+
const fromLoom = normalized === '.loom' || normalized.startsWith('.loom/');
|
|
882
|
+
const base = fromLoom ? paths.loom : paths.root;
|
|
883
|
+
const relativeRef = fromLoom ? normalized.slice('.loom'.length).replace(/^\//, '') : normalized;
|
|
884
|
+
const absolute = resolve(base, relativeRef);
|
|
885
|
+
const prefix = `${base}${process.platform === 'win32' ? '\\' : '/'}`;
|
|
886
|
+
if (!absolute.startsWith(prefix) && absolute !== base) throw new Error(`Unsafe context reference: ${ref}`);
|
|
565
887
|
if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
|
|
566
888
|
if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
|
|
567
889
|
return readFileSync(absolute, 'utf8');
|
|
@@ -575,18 +897,48 @@ export function checkProject(root = findRoot()) {
|
|
|
575
897
|
for (const ref of task.reads) {
|
|
576
898
|
try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
|
|
577
899
|
}
|
|
900
|
+
if (task.capability_hooks) {
|
|
901
|
+
for (const hook of task.capability_hooks) {
|
|
902
|
+
const nodeContent = extractCapabilityNode(paths, hook.node);
|
|
903
|
+
if (nodeContent === null) warnings.push(`${task.id} references missing capability node: ${hook.node}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const hasAcceptance = Array.isArray(task.acceptance) && task.acceptance.length > 0;
|
|
907
|
+
const hasDoneWhen = Array.isArray(task.done_when) && task.done_when.length > 0;
|
|
908
|
+
if (!hasAcceptance && hasDoneWhen) warnings.push(`${task.id} uses done_when[] without acceptance[] — consider migrating to structured acceptance for clearer verification`);
|
|
578
909
|
}
|
|
579
910
|
if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
|
|
580
911
|
if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
|
|
912
|
+
const decisionsContent = readFileSync(paths.decisions, 'utf8');
|
|
913
|
+
const affectedMatches = [...decisionsContent.matchAll(/Affected tasks: (.+)/g)];
|
|
914
|
+
const allAffected = new Set();
|
|
915
|
+
for (const match of affectedMatches) {
|
|
916
|
+
for (const taskId of match[1].split(',').map((s) => s.trim()).filter(Boolean)) allAffected.add(taskId);
|
|
917
|
+
}
|
|
918
|
+
for (const taskId of allAffected) {
|
|
919
|
+
const task = taskStore.tasks.find((item) => item.id === taskId);
|
|
920
|
+
if (task && task.status === 'done') warnings.push(`${taskId} is done but was marked affected by a decision; consider reopening if the change invalidates prior work`);
|
|
921
|
+
}
|
|
581
922
|
if (!listCapabilities(root).length) warnings.push('No capability dossier exists; acceptable only when specialist judgment would not change the work');
|
|
582
923
|
if (!listDesigns(root).length) warnings.push('No design document exists; PROJECT.md should remain a concise map of the whole');
|
|
924
|
+
if (!existsSync(paths.structure)) warnings.push('No STRUCTURE.md exists; declare where files go so the Agent does not guess');
|
|
925
|
+
else if (readFileSync(paths.structure, 'utf8').includes('Where implementation files go. Example:')) warnings.push('STRUCTURE.md still contains template instructions; customize it for this project');
|
|
583
926
|
for (const name of listDesigns(root)) {
|
|
584
927
|
if (readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.')) warnings.push(`Design document still contains template instructions: ${name}`);
|
|
585
928
|
}
|
|
586
929
|
for (const name of listCapabilities(root)) {
|
|
587
|
-
|
|
930
|
+
const content = readFileSync(capabilityPath(paths, name), 'utf8');
|
|
931
|
+
if (capabilityTemplateResidue(content)) warnings.push(`Capability dossier still contains template instructions: ${name}`);
|
|
932
|
+
if (content.includes('### C') && !content.includes('source:')) warnings.push(`Capability dossier has decision tree nodes without source citations: ${name}`);
|
|
933
|
+
const statusPath = join(paths.capabilities, name, 'status.json');
|
|
934
|
+
if (existsSync(statusPath)) {
|
|
935
|
+
const capStatus = readJson(statusPath, 'status.json');
|
|
936
|
+
if (capStatus.status && capStatus.status !== 'confirmed') warnings.push(`Capability ${name} is ${capStatus.status}, not confirmed; tasks referencing it proceed provisionally`);
|
|
937
|
+
}
|
|
588
938
|
}
|
|
589
|
-
|
|
939
|
+
const coverage = checkDeliverableCoverage(root);
|
|
940
|
+
if (coverage.uncovered > 0) warnings.push(`Uncovered deliverables: ${coverage.uncovered_items.map((item) => item.slug).join(', ')}`);
|
|
941
|
+
return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks), deliverable_coverage: { total: coverage.total, covered: coverage.covered, uncovered: coverage.uncovered } };
|
|
590
942
|
}
|
|
591
943
|
|
|
592
944
|
export function scaffoldEval(payload, root = findRoot()) {
|
|
@@ -596,6 +948,8 @@ export function scaffoldEval(payload, root = findRoot()) {
|
|
|
596
948
|
const dir = join(paths.eval, slug);
|
|
597
949
|
if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
|
|
598
950
|
mkdirSync(dir, { recursive: true });
|
|
951
|
+
const humanChannel = payload.human_channel || 'available';
|
|
952
|
+
if (!['available', 'unavailable'].includes(humanChannel)) throw new Error('human_channel must be available or unavailable');
|
|
599
953
|
const manifest = {
|
|
600
954
|
schema_version: 1,
|
|
601
955
|
id: payload.id,
|
|
@@ -610,6 +964,7 @@ export function scaffoldEval(payload, root = findRoot()) {
|
|
|
610
964
|
],
|
|
611
965
|
controls: {
|
|
612
966
|
same_model_tools_workspace_and_budget: true,
|
|
967
|
+
human_channel: humanChannel,
|
|
613
968
|
minimum_repetitions_per_condition: payload.repetitions || 3,
|
|
614
969
|
scripted_user_answers: true,
|
|
615
970
|
context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
|
|
@@ -621,8 +976,8 @@ export function scaffoldEval(payload, root = findRoot()) {
|
|
|
621
976
|
measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
|
|
622
977
|
};
|
|
623
978
|
atomicJson(join(dir, 'manifest.json'), manifest);
|
|
624
|
-
writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false })}\n`, 'utf8');
|
|
625
|
-
writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true })}\n`, 'utf8');
|
|
979
|
+
writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false, humanChannel })}\n`, 'utf8');
|
|
980
|
+
writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true, humanChannel })}\n`, 'utf8');
|
|
626
981
|
writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
|
|
627
|
-
return { scenario: payload.id, path:
|
|
982
|
+
return { scenario: payload.id, path: normalizeRef(paths, dir), controls: manifest.controls };
|
|
628
983
|
}
|