@haaaiawd/loom 1.3.1 → 2.0.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 +11 -86
- package/CONTRIBUTING.md +37 -0
- package/EVIL_EVAL.md +112 -0
- package/README.md +193 -445
- package/README.zh-CN.md +174 -0
- package/SECURITY.md +11 -0
- package/cli/bin/loom.js +171 -998
- package/cli/src/protocol.js +367 -0
- package/cli/src/store.js +626 -0
- package/design.md +194 -0
- package/docs/PROMPT_CATALOG.md +99 -0
- package/docs/RELEASE_CHECKLIST.md +53 -0
- package/docs/UX_FLOW.md +171 -0
- package/docs/brand/loom-mark.svg +18 -0
- package/docs/brand/loom-readme-header.svg +34 -0
- package/docs/brand/loom-readme-header.zh-CN.svg +29 -0
- package/docs/loom-eval-loop.drawio +21 -0
- package/docs/loom-eval-loop.svg +56 -0
- package/docs/loom-production-loop.drawio +41 -0
- package/docs/loom-production-loop.svg +92 -0
- package/package.json +43 -40
- package/EXTERNAL_ACQUISITION_DESIGN.md +0 -143
- package/cli/help/asset.md +0 -36
- package/cli/help/atelier.md +0 -37
- package/cli/help/atlas.md +0 -48
- package/cli/help/capability.md +0 -118
- package/cli/help/concepts.md +0 -105
- package/cli/help/doctor.md +0 -80
- package/cli/help/expertise.md +0 -52
- package/cli/help/loop.md +0 -134
- package/cli/help/patch.md +0 -33
- package/cli/help/proposals.md +0 -21
- package/cli/help/version.md +0 -136
- package/cli/help/workflow.md +0 -116
- package/cli/src/activate.js +0 -505
- package/cli/src/asset-library.js +0 -384
- package/cli/src/atelier.js +0 -331
- package/cli/src/atlas.js +0 -282
- package/cli/src/auto.js +0 -116
- package/cli/src/capability-graph.js +0 -724
- package/cli/src/capability-proposals.js +0 -225
- package/cli/src/diagnostics.js +0 -859
- package/cli/src/expertise-pack.js +0 -336
- package/cli/src/guide.js +0 -548
- package/cli/src/help.js +0 -41
- package/cli/src/init.js +0 -187
- package/cli/src/intent-draft.js +0 -303
- package/cli/src/intent-map.js +0 -747
- package/cli/src/patch.js +0 -214
- package/cli/src/philosophy.js +0 -331
- package/cli/src/shared/intent-ref.js +0 -38
- package/cli/src/shared/md-utils.js +0 -125
- package/cli/src/shared/paths.js +0 -73
- package/cli/src/shared/proof-reference.js +0 -19
- package/cli/src/shared/verification-method.js +0 -32
- package/cli/src/verify.js +0 -394
- package/cli/src/version.js +0 -134
- package/dimensions/AUTHORSHIP.md +0 -45
- package/dimensions/PART_DECOMPOSITION.md +0 -42
- package/dimensions/SEARCH_METHODOLOGY.md +0 -101
- package/dimensions/examples/AGENT_SYSTEM/README.md +0 -219
- package/dimensions/examples/CLI_TOOL/README.md +0 -163
- package/dimensions/universal/COLLABORATION_PHILOSOPHY.md +0 -28
- package/dimensions/universal/ENGINEERING_CREED.md +0 -30
- package/dimensions/universal/PRODUCT_PHILOSOPHY.md +0 -32
- package/meta/BASELINE.md +0 -91
- package/meta/INTENT_LOOP.md +0 -296
- package/meta/PHILOSOPHY_WEAVER.md +0 -110
- package/meta/ROLE_ACTIVATION.md +0 -114
- package/roles/architect.md +0 -92
- package/roles/forge.md +0 -110
- package/roles/impact-reviewer.md +0 -37
- package/roles/keeper.md +0 -113
- package/roles/visionary.md +0 -57
- package/templates/ASSET_LIBRARY_MANIFEST_TEMPLATE.json +0 -10
- package/templates/ATELIER_RECORD_TEMPLATE.json +0 -48
- package/templates/ATLAS_TEMPLATE.html +0 -104
- package/templates/CAPABILITY_BRIEF_TEMPLATE.md +0 -36
- package/templates/CAPABILITY_GRAPH_EXAMPLE.json +0 -188
- package/templates/CAPABILITY_GRAPH_TEMPLATE.json +0 -78
- package/templates/EXPERTISE_PACK_TEMPLATE.json +0 -22
- package/templates/INTENT_MAP_TEMPLATE.json +0 -85
- package/templates/PHILOSOPHY_TEMPLATE.md +0 -44
- package/templates/VISION_TEMPLATE.md +0 -44
package/cli/src/store.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import {
|
|
14
|
+
AGENT_PROTOCOL,
|
|
15
|
+
AGENT_ANCHOR,
|
|
16
|
+
CAPABILITY_TEMPLATE,
|
|
17
|
+
DESIGN_KINDS,
|
|
18
|
+
PROJECT_TEMPLATE,
|
|
19
|
+
designTemplate,
|
|
20
|
+
evalConditionPrompt,
|
|
21
|
+
evalJudgePrompt,
|
|
22
|
+
keeperProtocol,
|
|
23
|
+
shapingContext,
|
|
24
|
+
} from './protocol.js';
|
|
25
|
+
|
|
26
|
+
const SCHEMA_VERSION = 2;
|
|
27
|
+
const VALID_PROJECT_STATUS = new Set(['shaping', 'ready_for_keeper', 'build_ready', 'building', 'complete']);
|
|
28
|
+
const VALID_TASK_STATUS = new Set(['open', 'active', 'blocked', 'done']);
|
|
29
|
+
|
|
30
|
+
function now() {
|
|
31
|
+
return new Date().toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJson(path, label = basename(path)) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(`${label} cannot be read: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function atomicJson(path, value) {
|
|
43
|
+
const temp = `${path}.tmp-${process.pid}`;
|
|
44
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
45
|
+
renameSync(temp, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nextId(items, prefix) {
|
|
49
|
+
const max = items.reduce((highest, item) => {
|
|
50
|
+
const match = new RegExp(`^${prefix}-(\\d+)$`).exec(item.id || '');
|
|
51
|
+
return match ? Math.max(highest, Number(match[1])) : highest;
|
|
52
|
+
}, 0);
|
|
53
|
+
return `${prefix}-${String(max + 1).padStart(3, '0')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function asObjects(values, key) {
|
|
57
|
+
if (!values) return [];
|
|
58
|
+
if (!Array.isArray(values)) throw new Error(`${key} must be an array`);
|
|
59
|
+
return values.map((value) => typeof value === 'string' ? { [key]: value } : value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function findRoot(from = process.cwd()) {
|
|
63
|
+
let cursor = resolve(from);
|
|
64
|
+
while (true) {
|
|
65
|
+
if (existsSync(join(cursor, '.loom', 'state.json'))) return cursor;
|
|
66
|
+
const parent = dirname(cursor);
|
|
67
|
+
if (parent === cursor) break;
|
|
68
|
+
cursor = parent;
|
|
69
|
+
}
|
|
70
|
+
throw new Error('LOOM is not initialized. Run loom init in the project root.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loomPaths(root = findRoot()) {
|
|
74
|
+
const loom = join(root, '.loom');
|
|
75
|
+
return {
|
|
76
|
+
root,
|
|
77
|
+
loom,
|
|
78
|
+
state: join(loom, 'state.json'),
|
|
79
|
+
tasks: join(loom, 'tasks.json'),
|
|
80
|
+
project: join(loom, 'PROJECT.md'),
|
|
81
|
+
decisions: join(loom, 'DECISIONS.md'),
|
|
82
|
+
design: join(loom, 'design'),
|
|
83
|
+
capabilities: join(loom, 'capabilities'),
|
|
84
|
+
eval: join(loom, 'eval'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function initProject(root = process.cwd()) {
|
|
89
|
+
const paths = loomPathsForInit(root);
|
|
90
|
+
if (existsSync(paths.state)) return { initialized: false, root: paths.root, reason: 'already_initialized' };
|
|
91
|
+
if (existsSync(join(paths.loom, 'current')) || existsSync(join(paths.loom, 'v1'))) {
|
|
92
|
+
throw new Error('A legacy LOOM project was found. Back it up and migrate deliberately; v2 will not overwrite it.');
|
|
93
|
+
}
|
|
94
|
+
mkdirSync(paths.design, { recursive: true });
|
|
95
|
+
mkdirSync(paths.capabilities, { recursive: true });
|
|
96
|
+
mkdirSync(paths.eval, { recursive: true });
|
|
97
|
+
const state = {
|
|
98
|
+
schema_version: SCHEMA_VERSION,
|
|
99
|
+
project: { name: basename(paths.root), status: 'shaping', created_at: now(), updated_at: now() },
|
|
100
|
+
understanding: { confirmed: [], assumptions: [], unresolved: [] },
|
|
101
|
+
keeper: { status: 'not_run', attempts: [] },
|
|
102
|
+
};
|
|
103
|
+
atomicJson(paths.state, state);
|
|
104
|
+
atomicJson(paths.tasks, { schema_version: SCHEMA_VERSION, tasks: [] });
|
|
105
|
+
writeFileSync(paths.project, PROJECT_TEMPLATE, 'utf8');
|
|
106
|
+
writeFileSync(paths.decisions, '# Decision History\n\nCurrent truth belongs in PROJECT.md and linked design documents. This file preserves consequential superseding decisions.\n', 'utf8');
|
|
107
|
+
installAgentAnchor(paths.root);
|
|
108
|
+
return { initialized: true, root: paths.root, files: ['.loom/PROJECT.md', '.loom/DECISIONS.md', '.loom/state.json', '.loom/tasks.json', '.loom/design/', '.loom/capabilities/'] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loomPathsForInit(root) {
|
|
112
|
+
const absolute = resolve(root);
|
|
113
|
+
const loom = join(absolute, '.loom');
|
|
114
|
+
return { ...loomPathsShape(absolute, loom) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loomPathsShape(root, loom) {
|
|
118
|
+
return {
|
|
119
|
+
root,
|
|
120
|
+
loom,
|
|
121
|
+
state: join(loom, 'state.json'),
|
|
122
|
+
tasks: join(loom, 'tasks.json'),
|
|
123
|
+
project: join(loom, 'PROJECT.md'),
|
|
124
|
+
decisions: join(loom, 'DECISIONS.md'),
|
|
125
|
+
design: join(loom, 'design'),
|
|
126
|
+
capabilities: join(loom, 'capabilities'),
|
|
127
|
+
eval: join(loom, 'eval'),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function installAgentAnchor(root) {
|
|
132
|
+
const path = join(root, 'AGENTS.md');
|
|
133
|
+
const marker = '<!-- loom:v2 -->';
|
|
134
|
+
if (!existsSync(path)) writeFileSync(path, `${AGENT_ANCHOR}\n`, 'utf8');
|
|
135
|
+
else if (!readFileSync(path, 'utf8').includes(marker)) appendFileSync(path, `\n${AGENT_ANCHOR}\n`, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function loadProject(root = findRoot()) {
|
|
139
|
+
const paths = loomPaths(root);
|
|
140
|
+
mkdirSync(paths.design, { recursive: true });
|
|
141
|
+
mkdirSync(paths.capabilities, { recursive: true });
|
|
142
|
+
mkdirSync(paths.eval, { recursive: true });
|
|
143
|
+
const state = readJson(paths.state, 'state.json');
|
|
144
|
+
const taskStore = readJson(paths.tasks, 'tasks.json');
|
|
145
|
+
validateState(state);
|
|
146
|
+
validateTasks(taskStore.tasks);
|
|
147
|
+
return { paths, state, taskStore };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateState(state) {
|
|
151
|
+
if (state.schema_version !== SCHEMA_VERSION) throw new Error(`Unsupported schema_version ${state.schema_version}`);
|
|
152
|
+
if (!VALID_PROJECT_STATUS.has(state.project?.status)) throw new Error('Invalid project status');
|
|
153
|
+
for (const key of ['confirmed', 'assumptions', 'unresolved']) {
|
|
154
|
+
if (!Array.isArray(state.understanding?.[key])) throw new Error(`understanding.${key} must be an array`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateTasks(tasks) {
|
|
159
|
+
if (!Array.isArray(tasks)) throw new Error('tasks.json tasks must be an array');
|
|
160
|
+
const ids = new Set();
|
|
161
|
+
let active = 0;
|
|
162
|
+
for (const task of tasks) {
|
|
163
|
+
if (!task.id || ids.has(task.id)) throw new Error(`Task id is missing or duplicated: ${task.id || '<missing>'}`);
|
|
164
|
+
ids.add(task.id);
|
|
165
|
+
if (!task.title || !task.outcome) throw new Error(`${task.id} requires title and outcome`);
|
|
166
|
+
if (!VALID_TASK_STATUS.has(task.status)) throw new Error(`${task.id} has invalid status ${task.status}`);
|
|
167
|
+
if (!Array.isArray(task.done_when) || !task.done_when.length) throw new Error(`${task.id} requires done_when`);
|
|
168
|
+
if (!Array.isArray(task.depends_on) || !Array.isArray(task.reads)) throw new Error(`${task.id} dependencies and reads must be arrays`);
|
|
169
|
+
if (task.status === 'active') active += 1;
|
|
170
|
+
}
|
|
171
|
+
if (active > 1) throw new Error('Only one Task may be active');
|
|
172
|
+
for (const task of tasks) {
|
|
173
|
+
for (const dep of task.depends_on) if (!ids.has(dep)) throw new Error(`${task.id} depends on missing Task ${dep}`);
|
|
174
|
+
}
|
|
175
|
+
detectCycles(tasks);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function detectCycles(tasks) {
|
|
179
|
+
const map = new Map(tasks.map((task) => [task.id, task.depends_on]));
|
|
180
|
+
const visiting = new Set();
|
|
181
|
+
const visited = new Set();
|
|
182
|
+
function visit(id) {
|
|
183
|
+
if (visiting.has(id)) throw new Error(`Task dependency cycle includes ${id}`);
|
|
184
|
+
if (visited.has(id)) return;
|
|
185
|
+
visiting.add(id);
|
|
186
|
+
for (const dep of map.get(id) || []) visit(dep);
|
|
187
|
+
visiting.delete(id);
|
|
188
|
+
visited.add(id);
|
|
189
|
+
}
|
|
190
|
+
for (const id of map.keys()) visit(id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function recordUnderstanding(payload, root = findRoot()) {
|
|
194
|
+
const { paths, state } = loadProject(root);
|
|
195
|
+
for (const item of asObjects(payload.confirmed, 'text')) {
|
|
196
|
+
if (!item.text) throw new Error('Confirmed fact requires text');
|
|
197
|
+
state.understanding.confirmed.push({ id: nextId(state.understanding.confirmed, 'F'), text: item.text, source: item.source || 'conversation', at: now() });
|
|
198
|
+
}
|
|
199
|
+
for (const item of asObjects(payload.assumptions, 'text')) {
|
|
200
|
+
if (!item.text) throw new Error('Assumption requires text');
|
|
201
|
+
state.understanding.assumptions.push({ id: nextId(state.understanding.assumptions, 'A'), text: item.text, status: 'active', source: item.source || 'agent', at: now() });
|
|
202
|
+
}
|
|
203
|
+
for (const item of asObjects(payload.unresolved, 'question')) {
|
|
204
|
+
if (!item.question) throw new Error('Unresolved item requires question');
|
|
205
|
+
state.understanding.unresolved.push({ id: nextId(state.understanding.unresolved, 'Q'), question: item.question, impact: item.impact || 'medium', status: 'open', at: now() });
|
|
206
|
+
}
|
|
207
|
+
for (const item of payload.resolved || []) {
|
|
208
|
+
const target = state.understanding.unresolved.find((candidate) => candidate.id === item.id);
|
|
209
|
+
if (!target) throw new Error(`Unknown unresolved id ${item.id}`);
|
|
210
|
+
target.status = item.status === 'skipped' ? 'skipped' : 'resolved';
|
|
211
|
+
target.resolution = item.resolution || '';
|
|
212
|
+
target.resolved_at = now();
|
|
213
|
+
}
|
|
214
|
+
for (const id of payload.retire_assumptions || []) {
|
|
215
|
+
const target = state.understanding.assumptions.find((candidate) => candidate.id === id);
|
|
216
|
+
if (!target) throw new Error(`Unknown assumption id ${id}`);
|
|
217
|
+
target.status = 'retired';
|
|
218
|
+
}
|
|
219
|
+
if (payload.project_status) throw new Error('Project status is controlled by ready, Keeper, and Task commands');
|
|
220
|
+
for (const decision of payload.decisions || []) appendDecision(paths.decisions, decision, state);
|
|
221
|
+
state.project.updated_at = now();
|
|
222
|
+
atomicJson(paths.state, state);
|
|
223
|
+
return state.understanding;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function appendDecision(path, decision, state) {
|
|
227
|
+
if (!decision.title || !decision.decision || !decision.rationale) throw new Error('Decision requires title, decision, and rationale');
|
|
228
|
+
const prior = state.decision_ids || [];
|
|
229
|
+
const id = nextId(prior.map((value) => ({ id: value })), 'D');
|
|
230
|
+
state.decision_ids = [...prior, id];
|
|
231
|
+
const supersedes = decision.supersedes?.length ? decision.supersedes.join(', ') : 'none';
|
|
232
|
+
appendFileSync(path, `\n## ${id}: ${decision.title}\n\n- Current decision: ${decision.decision}\n- Rationale: ${decision.rationale}\n- Source: ${decision.source || 'conversation'}\n- Supersedes: ${supersedes}\n- Affects: ${(decision.affects || []).join(', ') || 'project-wide or not yet classified'}\n- Recorded: ${now()}\n`, 'utf8');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createDesign(slug, options = {}, root = findRoot()) {
|
|
236
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Design slug must use lowercase letters, numbers, and hyphens');
|
|
237
|
+
if (!options.title) throw new Error('Design document requires --title');
|
|
238
|
+
if (!DESIGN_KINDS.includes(options.kind)) throw new Error(`Design kind must be one of: ${DESIGN_KINDS.join(', ')}`);
|
|
239
|
+
const { paths } = loadProject(root);
|
|
240
|
+
const path = join(paths.design, `${slug}.md`);
|
|
241
|
+
if (existsSync(path)) throw new Error(`Design document already exists: ${slug}`);
|
|
242
|
+
writeFileSync(path, designTemplate({ title: options.title, kind: options.kind }), 'utf8');
|
|
243
|
+
return { slug, kind: options.kind, path: relative(paths.root, path).replaceAll('\\', '/') };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function listDesigns(root = findRoot()) {
|
|
247
|
+
const { paths } = loadProject(root);
|
|
248
|
+
return readdirSync(paths.design).filter((name) => name.endsWith('.md')).sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getDesign(slug, root = findRoot()) {
|
|
252
|
+
const { paths } = loadProject(root);
|
|
253
|
+
const name = slug.endsWith('.md') ? slug : `${slug}.md`;
|
|
254
|
+
if (basename(name) !== name) throw new Error('Invalid design document name');
|
|
255
|
+
const path = join(paths.design, name);
|
|
256
|
+
if (!existsSync(path)) throw new Error(`Design document not found: ${slug}`);
|
|
257
|
+
return readFileSync(path, 'utf8');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createCapability(slug, options = {}, root = findRoot()) {
|
|
261
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error('Capability slug must use lowercase letters, numbers, and hyphens');
|
|
262
|
+
if (!options.title) throw new Error('Capability requires --title');
|
|
263
|
+
const { paths } = loadProject(root);
|
|
264
|
+
const path = join(paths.capabilities, `${slug}.md`);
|
|
265
|
+
if (existsSync(path)) throw new Error(`Capability already exists: ${slug}`);
|
|
266
|
+
writeFileSync(path, CAPABILITY_TEMPLATE({ title: options.title }), 'utf8');
|
|
267
|
+
return { slug, path: relative(paths.root, path).replaceAll('\\', '/') };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function listCapabilities(root = findRoot()) {
|
|
271
|
+
const { paths } = loadProject(root);
|
|
272
|
+
return readdirSync(paths.capabilities).filter((name) => name.endsWith('.md')).sort();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function getCapability(slug, root = findRoot()) {
|
|
276
|
+
const { paths } = loadProject(root);
|
|
277
|
+
const name = slug.endsWith('.md') ? slug : `${slug}.md`;
|
|
278
|
+
if (basename(name) !== name) throw new Error('Invalid capability name');
|
|
279
|
+
const path = join(paths.capabilities, name);
|
|
280
|
+
if (!existsSync(path)) throw new Error(`Capability not found: ${slug}`);
|
|
281
|
+
return readFileSync(path, 'utf8');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function importTasks(payload, root = findRoot()) {
|
|
285
|
+
const { paths, taskStore } = loadProject(root);
|
|
286
|
+
const incoming = Array.isArray(payload) ? payload : payload.tasks;
|
|
287
|
+
if (!Array.isArray(incoming) || !incoming.length) throw new Error('Task plan requires a non-empty tasks array');
|
|
288
|
+
for (const raw of incoming) {
|
|
289
|
+
if (raw.status && raw.status !== 'open') throw new Error('New Work Map Tasks must start open; completion requires Task evidence');
|
|
290
|
+
const id = raw.id || nextId(taskStore.tasks, 'TASK');
|
|
291
|
+
if (taskStore.tasks.some((task) => task.id === id)) throw new Error(`Task already exists: ${id}`);
|
|
292
|
+
taskStore.tasks.push({
|
|
293
|
+
id,
|
|
294
|
+
title: raw.title,
|
|
295
|
+
outcome: raw.outcome,
|
|
296
|
+
done_when: raw.done_when || [],
|
|
297
|
+
boundaries: raw.boundaries || [],
|
|
298
|
+
depends_on: raw.depends_on || [],
|
|
299
|
+
reads: raw.reads || ['.loom/PROJECT.md'],
|
|
300
|
+
touches: raw.touches || [],
|
|
301
|
+
status: 'open',
|
|
302
|
+
progress: raw.progress || { completed: [], current: '', next: '' },
|
|
303
|
+
evidence: raw.evidence || [],
|
|
304
|
+
created_at: now(),
|
|
305
|
+
updated_at: now(),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
validateTasks(taskStore.tasks);
|
|
309
|
+
atomicJson(paths.tasks, taskStore);
|
|
310
|
+
return taskSummary(taskStore.tasks);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function taskSummary(tasks) {
|
|
314
|
+
return {
|
|
315
|
+
total: tasks.length,
|
|
316
|
+
open: tasks.filter((task) => task.status === 'open').length,
|
|
317
|
+
active: tasks.find((task) => task.status === 'active')?.id || null,
|
|
318
|
+
blocked: tasks.filter((task) => task.status === 'blocked').length,
|
|
319
|
+
done: tasks.filter((task) => task.status === 'done').length,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getTask(id, root = findRoot()) {
|
|
324
|
+
const { taskStore } = loadProject(root);
|
|
325
|
+
const task = id ? taskStore.tasks.find((item) => item.id === id) : nextTask(taskStore.tasks);
|
|
326
|
+
if (!task) throw new Error(id ? `Task not found: ${id}` : 'No executable Task is available');
|
|
327
|
+
return task;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function nextTask(tasks) {
|
|
331
|
+
const active = tasks.find((task) => task.status === 'active');
|
|
332
|
+
if (active) return active;
|
|
333
|
+
const done = new Set(tasks.filter((task) => task.status === 'done').map((task) => task.id));
|
|
334
|
+
return tasks.find((task) => task.status === 'open' && task.depends_on.every((id) => done.has(id))) || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function updateTask(id, patch, root = findRoot()) {
|
|
338
|
+
const { paths, taskStore } = loadProject(root);
|
|
339
|
+
const task = taskStore.tasks.find((item) => item.id === id);
|
|
340
|
+
if (!task) throw new Error(`Task not found: ${id}`);
|
|
341
|
+
const allowed = ['title', 'outcome', 'done_when', 'boundaries', 'depends_on', 'reads', 'touches', 'progress', 'evidence'];
|
|
342
|
+
for (const key of Object.keys(patch)) if (!allowed.includes(key)) throw new Error(`Task field cannot be updated: ${key}`);
|
|
343
|
+
Object.assign(task, patch, { updated_at: now() });
|
|
344
|
+
validateTasks(taskStore.tasks);
|
|
345
|
+
atomicJson(paths.tasks, taskStore);
|
|
346
|
+
return task;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function blockTask(id, payload, root = findRoot()) {
|
|
350
|
+
const task = getTask(id, root);
|
|
351
|
+
if (task.status !== 'active') throw new Error(`${id} is not active`);
|
|
352
|
+
if (!payload.reason || payload.reason.length < 10) throw new Error('Blocking a Task requires a concrete reason');
|
|
353
|
+
if (!Array.isArray(payload.recovery_conditions) || !payload.recovery_conditions.length) throw new Error('Blocking a Task requires recovery_conditions');
|
|
354
|
+
const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
|
|
355
|
+
const { paths, taskStore } = loadProject(root);
|
|
356
|
+
const target = taskStore.tasks.find((item) => item.id === id);
|
|
357
|
+
target.status = 'blocked';
|
|
358
|
+
target.block = { reason: payload.reason, recovery_conditions: payload.recovery_conditions, at: now() };
|
|
359
|
+
target.progress = { ...target.progress, current: `blocked: ${payload.reason}`, next: payload.recovery_conditions.join('; ') };
|
|
360
|
+
target.evidence = [...target.evidence, ...evidence];
|
|
361
|
+
target.updated_at = now();
|
|
362
|
+
validateTasks(taskStore.tasks);
|
|
363
|
+
atomicJson(paths.tasks, taskStore);
|
|
364
|
+
return target;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function reopenTask(id, options = {}, root = findRoot()) {
|
|
368
|
+
const { paths, taskStore } = loadProject(root);
|
|
369
|
+
if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
|
|
370
|
+
const task = taskStore.tasks.find((item) => item.id === id);
|
|
371
|
+
if (!task || !['blocked', 'done'].includes(task.status)) throw new Error(`${id} is neither blocked nor done`);
|
|
372
|
+
if (task.status === 'done' && (!options.reason || options.reason.length < 10)) throw new Error('Reopening a done Task requires a concrete --reason');
|
|
373
|
+
const priorStatus = task.status;
|
|
374
|
+
task.status = 'open';
|
|
375
|
+
if (priorStatus === 'done') {
|
|
376
|
+
task.evidence.push({ type: 'completion_reopened', reason: options.reason, at: now() });
|
|
377
|
+
task.progress = { ...task.progress, current: `completion reopened: ${options.reason}`, next: 'Re-run the Task and close every done condition with evidence.' };
|
|
378
|
+
}
|
|
379
|
+
task.reopened_at = now();
|
|
380
|
+
task.updated_at = now();
|
|
381
|
+
validateTasks(taskStore.tasks);
|
|
382
|
+
atomicJson(paths.tasks, taskStore);
|
|
383
|
+
const { state } = loadProject(root);
|
|
384
|
+
if (state.project.status === 'complete') {
|
|
385
|
+
state.project.status = 'building';
|
|
386
|
+
state.project.updated_at = now();
|
|
387
|
+
atomicJson(paths.state, state);
|
|
388
|
+
}
|
|
389
|
+
return task;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function startTask(id, root = findRoot()) {
|
|
393
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
394
|
+
if (!['passed', 'skipped'].includes(state.keeper.status)) throw new Error('The one-time Keeper handoff has not passed. Run loom keeper prompt.');
|
|
395
|
+
if (taskStore.tasks.some((task) => task.status === 'active')) throw new Error('Another Task is already active');
|
|
396
|
+
const task = taskStore.tasks.find((item) => item.id === id);
|
|
397
|
+
if (!task || task.status !== 'open') throw new Error(`${id} is not open`);
|
|
398
|
+
const done = new Set(taskStore.tasks.filter((item) => item.status === 'done').map((item) => item.id));
|
|
399
|
+
const missing = task.depends_on.filter((dep) => !done.has(dep));
|
|
400
|
+
if (missing.length) throw new Error(`${id} has incomplete dependencies: ${missing.join(', ')}`);
|
|
401
|
+
for (const ref of task.reads) readContextDocument(paths, ref);
|
|
402
|
+
task.status = 'active';
|
|
403
|
+
task.updated_at = now();
|
|
404
|
+
validateTasks(taskStore.tasks);
|
|
405
|
+
atomicJson(paths.tasks, taskStore);
|
|
406
|
+
state.project.status = 'building';
|
|
407
|
+
state.project.updated_at = now();
|
|
408
|
+
atomicJson(paths.state, state);
|
|
409
|
+
return task;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function completeTask(id, payload, root = findRoot()) {
|
|
413
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
414
|
+
const task = taskStore.tasks.find((item) => item.id === id);
|
|
415
|
+
if (!task) throw new Error(`Task not found: ${id}`);
|
|
416
|
+
if (task.status !== 'active') throw new Error(`${id} is not active`);
|
|
417
|
+
if (!Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Completing a Task requires concrete evidence');
|
|
418
|
+
if (!Array.isArray(payload.checks)) throw new Error('Completing a Task requires checks for every done_when criterion');
|
|
419
|
+
const criteria = new Set(task.done_when);
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
for (const check of payload.checks) {
|
|
422
|
+
if (!check || !criteria.has(check.criterion)) throw new Error('Task completion check must quote an exact done_when criterion');
|
|
423
|
+
if (seen.has(check.criterion)) throw new Error(`Duplicate Task completion check: ${check.criterion}`);
|
|
424
|
+
if (!Array.isArray(check.evidence) || !check.evidence.length) throw new Error(`Task completion check requires evidence: ${check.criterion}`);
|
|
425
|
+
seen.add(check.criterion);
|
|
426
|
+
}
|
|
427
|
+
const missingChecks = task.done_when.filter((criterion) => !seen.has(criterion));
|
|
428
|
+
if (missingChecks.length) throw new Error(`Task completion is missing done_when checks:\n- ${missingChecks.join('\n- ')}`);
|
|
429
|
+
task.status = 'done';
|
|
430
|
+
task.evidence = [...task.evidence, ...payload.evidence, { type: 'done_when_checks', checks: payload.checks, at: now() }];
|
|
431
|
+
task.progress = { ...task.progress, current: 'complete', next: '' };
|
|
432
|
+
task.updated_at = now();
|
|
433
|
+
validateTasks(taskStore.tasks);
|
|
434
|
+
atomicJson(paths.tasks, taskStore);
|
|
435
|
+
if (taskStore.tasks.length && taskStore.tasks.every((item) => item.status === 'done')) {
|
|
436
|
+
state.project.status = 'complete';
|
|
437
|
+
state.project.updated_at = now();
|
|
438
|
+
atomicJson(paths.state, state);
|
|
439
|
+
}
|
|
440
|
+
return task;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function markReady(root = findRoot()) {
|
|
444
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
445
|
+
const project = readFileSync(paths.project, 'utf8');
|
|
446
|
+
const designs = listDesigns(root);
|
|
447
|
+
const capabilities = listCapabilities(root);
|
|
448
|
+
const highOpen = state.understanding.unresolved.filter((item) => item.status === 'open' && item.impact === 'high');
|
|
449
|
+
const errors = [];
|
|
450
|
+
if (project.includes('> This is the concise entry point') || project.length < 400) errors.push('PROJECT.md still looks like a template');
|
|
451
|
+
if (!designs.length) errors.push('No design document exists; PROJECT.md is an index, not the entire project design');
|
|
452
|
+
const unfinishedDesigns = designs.filter((name) => readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.'));
|
|
453
|
+
if (unfinishedDesigns.length) errors.push(`Design documents still contain template instructions: ${unfinishedDesigns.join(', ')}`);
|
|
454
|
+
const unfinishedCapabilities = capabilities.filter((name) => readFileSync(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes'));
|
|
455
|
+
if (unfinishedCapabilities.length) errors.push(`Capability dossiers still contain template instructions: ${unfinishedCapabilities.join(', ')}`);
|
|
456
|
+
if (!taskStore.tasks.length) errors.push('The initial work map is empty');
|
|
457
|
+
if (highOpen.length) errors.push(`High-impact questions remain open: ${highOpen.map((item) => item.id).join(', ')}`);
|
|
458
|
+
const digest = projectDigest(paths, taskStore.tasks);
|
|
459
|
+
const latestKeeper = state.keeper.attempts.at(-1);
|
|
460
|
+
if (['needs_revision', 'blocked'].includes(state.keeper.status) && latestKeeper?.prepared_digest === digest) {
|
|
461
|
+
errors.push('Keeper requested revision, but project truth and Task definitions have not changed');
|
|
462
|
+
}
|
|
463
|
+
if (errors.length) throw new Error(`Project is not ready for Keeper:\n- ${errors.join('\n- ')}`);
|
|
464
|
+
state.project.status = 'ready_for_keeper';
|
|
465
|
+
state.project.updated_at = now();
|
|
466
|
+
state.keeper.prepared_digest = digest;
|
|
467
|
+
state.keeper.prepared_attempt = state.keeper.attempts.length + 1;
|
|
468
|
+
atomicJson(paths.state, state);
|
|
469
|
+
return { ready_for_keeper: true, digest: state.keeper.prepared_digest, next: 'Open a fresh Agent and run loom keeper prompt' };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function projectDigest(paths, tasks) {
|
|
473
|
+
const hash = createHash('sha256');
|
|
474
|
+
hash.update(readFileSync(paths.project));
|
|
475
|
+
hash.update(readFileSync(paths.decisions));
|
|
476
|
+
for (const name of readdirSync(paths.design).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.design, name)));
|
|
477
|
+
for (const name of readdirSync(paths.capabilities).filter((file) => file.endsWith('.md')).sort()) hash.update(readFileSync(join(paths.capabilities, name)));
|
|
478
|
+
hash.update(JSON.stringify(tasks.map(({ progress, evidence, status, updated_at, ...definition }) => definition)));
|
|
479
|
+
return hash.digest('hex');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function getKeeperPrompt(root = findRoot()) {
|
|
483
|
+
const { state } = loadProject(root);
|
|
484
|
+
if (state.project.status !== 'ready_for_keeper') throw new Error('Run loom project ready before Keeper handoff');
|
|
485
|
+
return keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function recordKeeper(payload, root = findRoot()) {
|
|
489
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
490
|
+
if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper result cannot be recorded before loom project ready');
|
|
491
|
+
if (!['passed', 'needs_revision', 'blocked'].includes(payload.verdict)) throw new Error('Keeper verdict must be passed, needs_revision, or blocked');
|
|
492
|
+
if (!payload.summary || !Array.isArray(payload.evidence) || !payload.evidence.length) throw new Error('Keeper result requires summary and evidence');
|
|
493
|
+
if (payload.evidence.some((item) => typeof item !== 'string' || !item.trim())) throw new Error('Keeper evidence entries must be non-empty strings');
|
|
494
|
+
if (payload.gaps !== undefined && !Array.isArray(payload.gaps)) throw new Error('Keeper gaps must be an array');
|
|
495
|
+
for (const gap of payload.gaps || []) {
|
|
496
|
+
if (typeof gap === 'string' && gap.trim()) continue;
|
|
497
|
+
if (gap && typeof gap === 'object' && typeof gap.gap === 'string' && gap.gap.trim()) continue;
|
|
498
|
+
throw new Error('Each Keeper gap must be a non-empty string or an object with a gap field');
|
|
499
|
+
}
|
|
500
|
+
if (!payload.run_id || payload.run_id.length < 6) throw new Error('Keeper result requires a unique fresh-thread run_id');
|
|
501
|
+
if (state.keeper.attempts.some((attempt) => attempt.run_id === payload.run_id)) throw new Error(`Keeper run_id was already used: ${payload.run_id}`);
|
|
502
|
+
if (!payload.prepared_digest || payload.prepared_digest !== state.keeper.prepared_digest) throw new Error('Keeper result prepared_digest does not match the current ready state');
|
|
503
|
+
const currentDigest = projectDigest(paths, taskStore.tasks);
|
|
504
|
+
if (currentDigest !== state.keeper.prepared_digest) throw new Error('Project truth changed after loom project ready; prepare a new Keeper attempt');
|
|
505
|
+
const attempt = { run_id: payload.run_id, prepared_digest: payload.prepared_digest, verdict: payload.verdict, summary: payload.summary, evidence: payload.evidence, gaps: payload.gaps || [], at: now() };
|
|
506
|
+
state.keeper.attempts.push(attempt);
|
|
507
|
+
state.keeper.status = payload.verdict;
|
|
508
|
+
if (payload.verdict === 'passed') state.project.status = 'build_ready';
|
|
509
|
+
else state.project.status = 'shaping';
|
|
510
|
+
state.project.updated_at = now();
|
|
511
|
+
atomicJson(paths.state, state);
|
|
512
|
+
return state.keeper;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function skipKeeper(reason, root = findRoot()) {
|
|
516
|
+
if (!reason || reason.length < 10) throw new Error('Skipping Keeper requires a concrete reason');
|
|
517
|
+
const { paths, state } = loadProject(root);
|
|
518
|
+
if (state.project.status !== 'ready_for_keeper') throw new Error('Keeper can only be skipped after loom project ready');
|
|
519
|
+
state.keeper.status = 'skipped';
|
|
520
|
+
state.keeper.skip_reason = reason;
|
|
521
|
+
state.project.status = 'build_ready';
|
|
522
|
+
state.project.updated_at = now();
|
|
523
|
+
atomicJson(paths.state, state);
|
|
524
|
+
return state.keeper;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function compileContext(options = {}, root = findRoot()) {
|
|
528
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
529
|
+
const designs = listDesigns(root);
|
|
530
|
+
const capabilities = listCapabilities(root);
|
|
531
|
+
const summary = taskSummary(taskStore.tasks);
|
|
532
|
+
const task = options.taskId ? getTask(options.taskId, root) : taskStore.tasks.find((item) => item.status === 'active');
|
|
533
|
+
const blocks = [AGENT_PROTOCOL, shapingContext({ state, taskSummary: summary, capabilityNames: capabilities, designNames: designs, forKeeper: Boolean(options.keeper) })];
|
|
534
|
+
blocks.push(`## Project whole (${relative(paths.root, paths.project).replaceAll('\\', '/')})\n\n${readFileSync(paths.project, 'utf8')}`);
|
|
535
|
+
if (options.keeper) {
|
|
536
|
+
blocks.unshift(keeperProtocol({ attemptNumber: state.keeper.prepared_attempt, preparedDigest: state.keeper.prepared_digest }));
|
|
537
|
+
blocks.push(`## Decision history (${relative(paths.root, paths.decisions).replaceAll('\\', '/')})\n\n${readFileSync(paths.decisions, 'utf8')}`);
|
|
538
|
+
blocks.push(`## Work map summary\n\n${JSON.stringify(summary, null, 2)}\n\nFirst executable Task:\n\n${JSON.stringify(nextTask(taskStore.tasks), null, 2)}`);
|
|
539
|
+
for (const name of designs) blocks.push(`## Design document: ${name}\n\n${getDesign(name, root)}`);
|
|
540
|
+
for (const name of capabilities) blocks.push(`## Capability dossier: ${name}\n\n${getCapability(name, root)}`);
|
|
541
|
+
} else if (task) {
|
|
542
|
+
blocks.push(`## Active Task\n\n${JSON.stringify(task, null, 2)}`);
|
|
543
|
+
for (const ref of task.reads) {
|
|
544
|
+
const content = readContextDocument(paths, ref);
|
|
545
|
+
if (content && ref.replaceAll('\\', '/') !== normalizeRef(paths, paths.project)) blocks.push(`## Task context: ${ref}\n\n${content}`);
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
blocks.push(`## On-demand project context\n\n- Decision history: .loom/DECISIONS.md (read when correction or lineage matters)\n${designs.length ? designs.map((name) => `- Design: .loom/design/${name}`).join('\n') : '- No design documents yet; split the whole according to consequential systems and decisions.'}\n${capabilities.length ? capabilities.map((name) => `- Professional capability: .loom/capabilities/${name}`).join('\n') : '- No professional capability dossiers yet; create separate field dossiers only where expertise changes the work.'}`);
|
|
549
|
+
}
|
|
550
|
+
return blocks.filter(Boolean).join('\n\n---\n\n');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function normalizeRef(paths, absolute) {
|
|
554
|
+
return relative(paths.root, absolute).replaceAll('\\', '/');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function readContextDocument(paths, ref) {
|
|
558
|
+
const normalized = ref.replaceAll('\\', '/');
|
|
559
|
+
if (isAbsolute(normalized) || normalized.startsWith('/') || normalized.includes('..')) throw new Error(`Unsafe context reference: ${ref}`);
|
|
560
|
+
const absolute = resolve(paths.root, normalized);
|
|
561
|
+
const rootPrefix = `${paths.root}${process.platform === 'win32' ? '\\' : '/'}`;
|
|
562
|
+
if (!absolute.startsWith(rootPrefix) && absolute !== paths.root) throw new Error(`Unsafe context reference: ${ref}`);
|
|
563
|
+
if (!existsSync(absolute)) throw new Error(`Task context file does not exist: ${ref}`);
|
|
564
|
+
if (!statSync(absolute).isFile()) throw new Error(`Task context must name a file, not a directory: ${ref}`);
|
|
565
|
+
return readFileSync(absolute, 'utf8');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function checkProject(root = findRoot()) {
|
|
569
|
+
const { paths, state, taskStore } = loadProject(root);
|
|
570
|
+
const errors = [];
|
|
571
|
+
const warnings = [];
|
|
572
|
+
for (const task of taskStore.tasks) {
|
|
573
|
+
for (const ref of task.reads) {
|
|
574
|
+
try { readContextDocument(paths, ref); } catch (error) { errors.push(`${task.id}: ${error.message}`); }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (state.project.status === 'build_ready' && !['passed', 'skipped'].includes(state.keeper.status)) errors.push('Project is build_ready without Keeper pass or explicit skip');
|
|
578
|
+
if (state.understanding.unresolved.some((item) => item.status === 'open' && item.impact === 'high')) warnings.push('High-impact uncertainty remains open');
|
|
579
|
+
if (!listCapabilities(root).length) warnings.push('No capability dossier exists; acceptable only when specialist judgment would not change the work');
|
|
580
|
+
if (!listDesigns(root).length) warnings.push('No design document exists; PROJECT.md should remain a concise map of the whole');
|
|
581
|
+
for (const name of listDesigns(root)) {
|
|
582
|
+
if (readFileSync(join(paths.design, name), 'utf8').includes('Describe the project-specific decision, mechanism, boundary, or evidence owned by this section.')) warnings.push(`Design document still contains template instructions: ${name}`);
|
|
583
|
+
}
|
|
584
|
+
for (const name of listCapabilities(root)) {
|
|
585
|
+
if (readFileSync(join(paths.capabilities, name), 'utf8').includes('Name the established field, what expertise it contributes')) warnings.push(`Capability dossier still contains template instructions: ${name}`);
|
|
586
|
+
}
|
|
587
|
+
return { healthy: errors.length === 0, errors, warnings, summary: taskSummary(taskStore.tasks) };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function scaffoldEval(payload, root = findRoot()) {
|
|
591
|
+
if (!payload.id || !payload.title || !payload.brief) throw new Error('Eval scenario requires id, title, and brief');
|
|
592
|
+
const { paths } = loadProject(root);
|
|
593
|
+
const slug = payload.id.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
594
|
+
const dir = join(paths.eval, slug);
|
|
595
|
+
if (existsSync(dir)) throw new Error(`Eval scenario already exists: ${payload.id}`);
|
|
596
|
+
mkdirSync(dir, { recursive: true });
|
|
597
|
+
const manifest = {
|
|
598
|
+
schema_version: 1,
|
|
599
|
+
id: payload.id,
|
|
600
|
+
title: payload.title,
|
|
601
|
+
brief: payload.brief,
|
|
602
|
+
primary_comparison: 'same capable Agent without LOOM vs with LOOM',
|
|
603
|
+
hidden_user_facts: payload.hidden_user_facts || [],
|
|
604
|
+
success_criteria: payload.success_criteria || [],
|
|
605
|
+
conditions: [
|
|
606
|
+
{ id: 'baseline', framework: 'none', instruction: 'Work normally with all ordinary Agent capabilities and tools.' },
|
|
607
|
+
{ id: 'loom', framework: 'loom-v2', instruction: 'Use LOOM as invisible Agent continuity infrastructure.' },
|
|
608
|
+
],
|
|
609
|
+
controls: {
|
|
610
|
+
same_model_tools_workspace_and_budget: true,
|
|
611
|
+
minimum_repetitions_per_condition: payload.repetitions || 3,
|
|
612
|
+
scripted_user_answers: true,
|
|
613
|
+
context_reset_points: payload.context_reset_points || ['after-shaping', 'mid-task'],
|
|
614
|
+
blind_pairwise_order_swap: true,
|
|
615
|
+
anonymization_preserves_relative_layout: true,
|
|
616
|
+
judge_packet_preflight_required: true,
|
|
617
|
+
condition_output_digest_manifest: true,
|
|
618
|
+
},
|
|
619
|
+
measures: ['intent_fidelity', 'question_value', 'whole_project_coverage', 'capability_depth', 'buildability', 'continuity_after_reset', 'user_burden', 'cost_and_time'],
|
|
620
|
+
};
|
|
621
|
+
atomicJson(join(dir, 'manifest.json'), manifest);
|
|
622
|
+
writeFileSync(join(dir, 'baseline-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: false })}\n`, 'utf8');
|
|
623
|
+
writeFileSync(join(dir, 'loom-prompt.md'), `${evalConditionPrompt({ brief: payload.brief, loom: true })}\n`, 'utf8');
|
|
624
|
+
writeFileSync(join(dir, 'judge-prompt.md'), `${evalJudgePrompt()}\n`, 'utf8');
|
|
625
|
+
return { scenario: payload.id, path: relative(paths.root, dir).replaceAll('\\', '/'), controls: manifest.controls };
|
|
626
|
+
}
|