@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/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
- AGENT_PROTOCOL,
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 loom = join(root, '.loom');
76
- return {
77
- root,
78
- loom,
79
- state: join(loom, 'state.json'),
80
- tasks: join(loom, 'tasks.json'),
81
- project: join(loom, 'PROJECT.md'),
82
- decisions: join(loom, 'DECISIONS.md'),
83
- design: join(loom, 'design'),
84
- capabilities: join(loom, 'capabilities'),
85
- eval: join(loom, 'eval'),
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 { ...loomPathsShape(absolute, loom) };
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 path = join(paths.capabilities, `${slug}.md`);
266
- if (existsSync(path)) throw new Error(`Capability already exists: ${slug}`);
267
- writeFileSync(path, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
268
- return { slug, path: relative(paths.root, path).replaceAll('\\', '/') };
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
- return readdirSync(paths.capabilities).filter((name) => name.endsWith('.md')).sort();
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 name = slug.endsWith('.md') ? slug : `${slug}.md`;
279
- if (basename(name) !== name) throw new Error('Invalid capability name');
280
- const path = join(paths.capabilities, name);
281
- if (!existsSync(path)) throw new Error(`Capability not found: ${slug}`);
282
- return readFileSync(path, 'utf8');
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
- if (!Array.isArray(payload.checks)) throw new Error('Completing a Task requires checks for every done_when criterion');
420
- const criteria = new Set(task.done_when);
421
- const seen = new Set();
422
- for (const check of payload.checks) {
423
- if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
424
- if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
425
- if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
426
- seen.add(check.criterion);
427
- }
428
- const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
429
- if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
430
- task.status = 'done';
431
- task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
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(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes'));
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 readdirSync(paths.capabilities).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.capabilities, name)));
745
+ for (const name of listCapabilities()) {
746
+ hash.update(readFileSync(capabilityPath(paths, name), 'utf8'));
747
+ const statusPath = join(paths.capabilities, name, 'status.json');
748
+ if (existsSync(statusPath)) hash.update(readFileSync(statusPath, 'utf8'));
749
+ }
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 blocks = [AGENT_PROTOCOL, shapingContext({ state, taskSummary: summary, capabilityNames: capabilities, designNames: designs, forKeeper: Boolean(options.keeper) })];
535
- blocks.push(`## Project whole (${relative(paths.root, paths.project).replaceAll('\\', '/')})\n\n${readFileSync(paths.project, 'utf8')}`);
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 (${relative(paths.root, paths.decisions).replaceAll('\\', '/')})\n\n${readFileSync(paths.decisions, 'utf8')}`);
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 absolute = resolve(paths.root, normalized);
563
- const rootPrefix = `${paths.root}${process.platform === 'win32' ? '\\' : '/'}`;
564
- if (!absolute.startsWith(rootPrefix) && absolute !== paths.root) throw new Error(`Unsafe context reference: ${ref}`);
881
+ const fromLoom = normalized === '.loom' || normalized.startsWith('.loom/');
882
+ const base = fromLoom ? paths.loom : paths.root;
883
+ const relativeRef = fromLoom ? normalized.slice('.loom'.length).replace(/^\//, '') : normalized;
884
+ const absolute = resolve(base, relativeRef);
885
+ const prefix = `${base}${process.platform === 'win32' ? '\\' : '/'}`;
886
+ if (!absolute.startsWith(prefix) && absolute !== base) throw new Error(`Unsafe context reference: ${ref}`);
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
- if (readFileSync(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes')) warnings.push(`Capability dossier still contains template instructions: ${name}`);
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
- return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks) };
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: relative(paths.root, dir).replaceAll('\\', '/'), controls: manifest.controls };
982
+ return { scenario: payload.id, path: normalizeRef(paths, dir), controls: manifest.controls };
628
983
  }