@haaaiawd/loom 1.0.0 → 1.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.
@@ -27,7 +27,18 @@ loom activate visionary
27
27
 
28
28
  输出产品目标、成功图景、非目标与 Intent narrative。Visionary 不写 acceptance、DAG 或架构。
29
29
 
30
- ## 3. Contract — Architect
30
+ ## 3. Capability Graph — Architect
31
+
32
+ ```bash
33
+ loom activate architect
34
+ loom capability graph
35
+ loom capability frontier
36
+ loom capability coverage
37
+ ```
38
+
39
+ 在写 Intent Map 前,Architect 先把愿景展开为项目问题面、能力缺口、风险与证据。图谱不是待办列表:高影响节点必须继续展开、形成项目化 Capability Brief、编译为 Intent,或带理由地延后/排除。每个 Intent 必须回链图谱;只有边界清楚且可独立验证的结果才进入 Intent Map。
40
+
41
+ ## 4. Contract — Architect
31
42
 
32
43
  ```bash
33
44
  loom activate architect
@@ -41,7 +52,7 @@ Architect 产出系统边界、Intent DAG、完成契约和可选质量契约,
41
52
  完成契约定义 **Reliability Floor**:做到什么才算可靠完成。
42
53
  质量契约定义 **Distinctive Ceiling**:什么可观察差异让结果不止合格。
43
54
 
44
- ## 4. Quality Engine — Forge 与 Keeper
55
+ ## 5. Quality Engine — Forge 与 Keeper
45
56
 
46
57
  ```bash
47
58
  loom intent next
@@ -67,12 +78,13 @@ loom intent done <id>
67
78
 
68
79
  复杂或混合判定使用 `loom verify write --json-file <path>`。
69
80
 
70
- ## 5. Reflow
81
+ ## 6. Reflow
71
82
 
72
83
  验证偏离时,不要把所有问题都扔回 Forge:
73
84
 
74
85
  - Doctrine 不足 → Weaver
75
86
  - 产品目标错误 → Visionary
87
+ - 问题面、能力缺口或 Intent 路由遗漏 → Architect 更新 Capability Graph
76
88
  - 契约、边界或依赖错误 → Architect
77
89
  - 专业判断或实现不足 → Forge
78
90
  - 证据不足 → Keeper 补证或 `pending_human`
@@ -80,7 +92,7 @@ loom intent done <id>
80
92
  连续三次 `deviated` 自动升级为 `blocked`。所有当前 revision 和当前验证 epoch 的 Intent 都有最新 passed
81
93
  记录,且没有 `needs_review`、`loom doctor` 没有 fatal/high 风险时,本轮收敛。
82
94
 
83
- ## 6. 演进
95
+ ## 7. 演进
84
96
 
85
97
  - Patch:不改变 Intent 语义,验证后记录 changelog。
86
98
  - Minor:用 `loom intent add|revise` 创建 draft,经限定作用域的 Visionary/Architect 更新后 finalize。
@@ -8,6 +8,8 @@ import { getIntentDraft } from './intent-draft.js';
8
8
  import { getPhilosophy } from './philosophy.js';
9
9
  import { getVerificationContract } from './verify.js';
10
10
  import { extractMdSection } from './shared/md-utils.js';
11
+ import { compileCapabilityInputs } from './capability-graph.js';
12
+ import { getAtelierRecord } from './atelier.js';
11
13
 
12
14
  const VALID_ROLES = ['weaver', 'visionary', 'architect', 'forge', 'keeper'];
13
15
 
@@ -130,6 +132,12 @@ function compileObjective(role, versionDir, intentId) {
130
132
  }
131
133
 
132
134
  const intent = getIntent(versionDir, intentId);
135
+ if (['forge', 'keeper'].includes(role)) {
136
+ const unfinishedDependencies = intent.depends_on.filter((dependencyId) => getIntent(versionDir, dependencyId).status !== 'completed');
137
+ if (unfinishedDependencies.length) {
138
+ throw new Error(`Intent ${intentId} 的依赖尚未闭合: ${unfinishedDependencies.join(', ')}。不得通过 activate --intent 绕过执行顺序;先运行 loom intent next 或完成依赖 Intent。`);
139
+ }
140
+ }
133
141
  const narrative = getNarrative(versionDir, intentId);
134
142
  const objectiveView = {
135
143
  id: intent.id,
@@ -241,19 +249,57 @@ function compileProjectJudgment(role, versionDir, objective) {
241
249
  return blocks.join('\n\n');
242
250
  }
243
251
 
244
- function compileExpertiseInputs(role, objective) {
252
+ function compileExpertiseInputs(role, versionDir, objective) {
245
253
  const subject = objective.intent || objective.draft;
246
254
  if (!subject || !['architect', 'forge', 'keeper'].includes(role)) {
247
255
  return '当前阶段不编译任务级 Expertise Pack。';
248
256
  }
249
257
  const needs = Array.isArray(subject.capability_needs) ? subject.capability_needs : [];
258
+ const qualityStrategy = subject.quality_strategy ?? 'adaptive';
250
259
  const lines = [
251
260
  `- capability_needs: ${needs.length ? needs.join(', ') : '未声明;按当前任务发现必要能力'}`,
252
261
  `- creative_scope: ${subject.creative_scope || '未声明;遵循最小完整干预'}`,
262
+ `- quality_strategy: ${qualityStrategy}`,
253
263
  '- Skill、工具和资产名称只代表可发现入口;实际检查并加载后才进入 Expertise Pack。',
254
264
  ];
265
+ if (versionDir && objective.intent) {
266
+ const compiled = compileCapabilityInputs(versionDir, objective.intent.id);
267
+ if (!compiled.available) {
268
+ lines.push(`- Capability Graph: ${compiled.warnings.join(' ')}`);
269
+ } else if (compiled.nodes.length === 0) {
270
+ lines.push('- Capability Graph: 当前 Intent 没有回链节点;不得凭任务标题猜测能力,回流 Architect 补图谱或明确兼容原因。');
271
+ } else {
272
+ lines.push('- Capability Graph: 以下节点是本 Intent 的能力与风险输入:');
273
+ for (const node of compiled.nodes) {
274
+ lines.push(` - ${node.id} [${node.kind}/${node.impact}] ${node.title}${node.question ? ` — ${node.question}` : ''}`);
275
+ }
276
+ for (const brief of compiled.briefs) {
277
+ lines.push(`\n### Capability Brief: ${brief.node_id}\n\n${brief.content.trim()}`);
278
+ }
279
+ for (const warning of compiled.warnings) lines.push(`- Capability Graph warning: ${warning}`);
280
+ }
281
+ }
255
282
  if (role === 'keeper') {
256
283
  lines.push('- 不继承 Forge Expertise Pack;按契约独立准备验证能力。');
284
+ if (qualityStrategy === 'atelier') {
285
+ const record = getAtelierRecord(versionDir, subject.id);
286
+ lines.push(
287
+ '- 当前 Intent 使用 Atelier:以下 Record 是创作证据入口,不是通过结论。',
288
+ `\n### Atelier Record\n\n\`\`\`json\n${JSON.stringify(record, null, 2)}\n\`\`\``,
289
+ );
290
+ }
291
+ }
292
+ if (role === 'forge' && qualityStrategy === 'atelier') {
293
+ const authorshipPath = join(getLoomRoot(), 'dimensions', 'AUTHORSHIP.md');
294
+ if (!existsSync(authorshipPath)) {
295
+ throw new Error(`Atelier 方法文件不存在: ${authorshipPath}`);
296
+ }
297
+ lines.push(
298
+ '- 当前 Intent 进入 Atelier Path:先冻结基线并形成 Authorial Stance,再生成机制不同的候选。',
299
+ `- Atelier Record: \`09_ATELIER/${subject.id}.json\`;候选必须绑定 stance_revision。`,
300
+ '- Author 只能修正局部创作假设;结构性发现提交 provenance-backed Capability Graph proposal,由 Architect 裁决。',
301
+ `\n### Authorship Method\n\n${readFileSync(authorshipPath, 'utf-8').trim()}`,
302
+ );
257
303
  }
258
304
  return lines.join('\n');
259
305
  }
@@ -263,6 +309,7 @@ function compileWorkingFacts(versionDir, objective) {
263
309
  const subject = objective.intent || objective.draft;
264
310
  const refs = [
265
311
  '- architecture: `.loom/.../02_ARCHITECTURE.md`(只读取与当前决定相关部分)',
312
+ '- capability_graph: 从 `07_CAPABILITY_GRAPH.json` 查询与当前 Intent 回链的能力、风险与 Brief;不能用会话记忆补全未路由分支。',
266
313
  '- artifacts: 从真实工作区检查,不从会话记忆猜测。',
267
314
  ];
268
315
  const systemId = subject?._optional?.system_id || subject?.system_id;
@@ -294,7 +341,7 @@ export function activateRole(role, versionDir, intentId = null) {
294
341
  section('3. Hard Invariants', compileInvariants(role, versionDir)),
295
342
  section('4. Success Contracts', compileContracts(role, versionDir, objective)),
296
343
  section('5. Project Judgment', compileProjectJudgment(role, versionDir, objective)),
297
- section('6. Expertise Inputs', compileExpertiseInputs(role, objective)),
344
+ section('6. Expertise Inputs', compileExpertiseInputs(role, versionDir, objective)),
298
345
  section('7. Working Facts', compileWorkingFacts(versionDir, objective)),
299
346
  section('8. Role Contract / Output / Reflow / Stop', readRole(role)),
300
347
  ];
@@ -0,0 +1,384 @@
1
+ // asset-library — versioned, local-first source of truth for project assets.
2
+ // It deliberately manages bytes and provenance; it never promises a remote URL is renderable.
3
+
4
+ import { closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { createHash } from 'node:crypto';
6
+ import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
7
+ import { getCapabilityGraphPath, loadCapabilityGraph, validateCapabilityGraph } from './capability-graph.js';
8
+ import { readJsonFile } from './shared/md-utils.js';
9
+
10
+ const ROOT_DIR = '08_ASSET_LIBRARY';
11
+ const MANIFEST_FILE = 'manifest.json';
12
+ const FILES_DIR = 'files';
13
+ const IMPORT_JOURNAL_FILE = '.asset-import-journal.json';
14
+ const KINDS = ['image', 'video', 'audio', 'document', 'model', 'other'];
15
+ const STATUSES = ['active', 'archived', 'rejected'];
16
+ const APPROVALS = ['approved', 'pending', 'rejected'];
17
+
18
+ export function getAssetLibraryDir(versionDir) { return join(versionDir, ROOT_DIR); }
19
+ export function getAssetManifestPath(versionDir) { return join(getAssetLibraryDir(versionDir), MANIFEST_FILE); }
20
+ export function getAssetFilesDir(versionDir) { return join(getAssetLibraryDir(versionDir), FILES_DIR); }
21
+ export function getAssetImportJournalPath(versionDir) { return join(getAssetLibraryDir(versionDir), IMPORT_JOURNAL_FILE); }
22
+
23
+ function removeIfExists(path) {
24
+ if (existsSync(path)) unlinkSync(path);
25
+ }
26
+
27
+ function fsyncFile(path) {
28
+ const fd = openSync(path, 'r');
29
+ try {
30
+ fsyncSync(fd);
31
+ } catch (error) {
32
+ // Some Windows/virtual filesystems reject fsync even for a writable regular
33
+ // file. Rename + journal recovery still preserve consistency; do not turn
34
+ // that platform limitation into an unusable importer.
35
+ if (!['EPERM', 'EINVAL'].includes(error.code)) throw error;
36
+ } finally { closeSync(fd); }
37
+ }
38
+
39
+ function transactionTempPath(path, id) {
40
+ return join(resolve(path, '..'), `.${basename(path)}.loom-import-${id}.tmp`);
41
+ }
42
+
43
+ // A single rename is atomic on the same filesystem. This does not make the whole
44
+ // import atomic across files; the journal below makes that multi-file change recoverable.
45
+ function writeFileAtomically(path, content, transactionId) {
46
+ const temp = transactionTempPath(path, transactionId);
47
+ writeFileSync(temp, content, 'utf-8');
48
+ fsyncFile(temp);
49
+ renameSync(temp, path);
50
+ }
51
+
52
+ function snapshot(path) {
53
+ return existsSync(path)
54
+ ? { exists: true, content_base64: readFileSync(path).toString('base64') }
55
+ : { exists: false };
56
+ }
57
+
58
+ function restoreSnapshot(path, saved, transactionId) {
59
+ if (!saved || !saved.exists) {
60
+ removeIfExists(path);
61
+ return;
62
+ }
63
+ writeFileAtomically(path, Buffer.from(saved.content_base64, 'base64'), transactionId);
64
+ }
65
+
66
+ function readImportJournal(versionDir) {
67
+ const path = getAssetImportJournalPath(versionDir);
68
+ if (!existsSync(path)) return null;
69
+ const journal = readJsonFile(path, 'Asset import recovery journal');
70
+ if (!journal || journal.schema !== 'loom.asset-import-recovery.v1' || typeof journal.transaction_id !== 'string') {
71
+ throw new Error(`unrecoverable Asset import journal at ${ROOT_DIR}/${IMPORT_JOURNAL_FILE}; restore the manifest and graph from backup before importing again`);
72
+ }
73
+ return journal;
74
+ }
75
+
76
+ function writeImportJournal(versionDir, journal) {
77
+ mkdirSync(getAssetLibraryDir(versionDir), { recursive: true });
78
+ writeFileAtomically(getAssetImportJournalPath(versionDir), `${JSON.stringify(journal, null, 2)}\n`, journal.transaction_id);
79
+ }
80
+
81
+ /**
82
+ * Roll back an interrupted import before any caller reads or writes this library.
83
+ * This is deliberately recovery, not a claim of cross-file OS atomicity.
84
+ */
85
+ export function recoverAssetImportTransaction(versionDir) {
86
+ const journal = readImportJournal(versionDir);
87
+ if (!journal) return { recovered: false };
88
+ const transactionId = journal.transaction_id;
89
+ try {
90
+ const destination = safeAssetPath(versionDir, journal.destination_path);
91
+ const temporaryAsset = safeAssetPath(versionDir, journal.temporary_asset_path);
92
+ restoreSnapshot(getAssetManifestPath(versionDir), journal.before.manifest, transactionId);
93
+ if (journal.before.graph) restoreSnapshot(getCapabilityGraphPath(versionDir), journal.before.graph, transactionId);
94
+ removeIfExists(destination);
95
+ removeIfExists(temporaryAsset);
96
+ removeIfExists(transactionTempPath(getAssetManifestPath(versionDir), transactionId));
97
+ if (journal.before.graph) removeIfExists(transactionTempPath(getCapabilityGraphPath(versionDir), transactionId));
98
+ removeIfExists(getAssetImportJournalPath(versionDir));
99
+ return { recovered: true, transaction_id: transactionId, stage: journal.stage || 'unknown' };
100
+ } catch (error) {
101
+ throw new Error(`unfinished Asset import transaction ${transactionId} could not be recovered: ${error.message}`);
102
+ }
103
+ }
104
+
105
+ function nonEmpty(value, label, errors) {
106
+ if (typeof value !== 'string' || value.trim() === '') errors.push(`${label} must be a non-empty string`);
107
+ }
108
+
109
+ function safeAssetPath(versionDir, storedPath) {
110
+ if (typeof storedPath !== 'string' || !storedPath) throw new Error('asset.path must be a non-empty relative path');
111
+ if (isAbsolute(storedPath)) throw new Error(`asset.path must not be absolute: ${storedPath}`);
112
+ const root = resolve(getAssetLibraryDir(versionDir));
113
+ const candidate = resolve(root, storedPath);
114
+ const rel = relative(root, candidate);
115
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`asset.path escapes Asset Library: ${storedPath}`);
116
+ const filesRoot = resolve(getAssetFilesDir(versionDir));
117
+ const filesRel = relative(filesRoot, candidate);
118
+ if (filesRel.startsWith('..') || isAbsolute(filesRel)) throw new Error(`asset.path must be within ${ROOT_DIR}/${FILES_DIR}: ${storedPath}`);
119
+ return candidate;
120
+ }
121
+
122
+ function hashFile(path) {
123
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
124
+ }
125
+
126
+ function normalizeTags(tags) {
127
+ const raw = Array.isArray(tags) ? tags : String(tags || '').split(',');
128
+ const seen = new Set();
129
+ for (const tag of raw) {
130
+ const normalized = String(tag).trim().replace(/\s+/g, ' ');
131
+ if (normalized) seen.add(normalized);
132
+ }
133
+ return [...seen];
134
+ }
135
+
136
+ function validateRecord(id, asset, versionDir, errors, { checkBytes = true } = {}) {
137
+ if (!asset || typeof asset !== 'object' || Array.isArray(asset)) {
138
+ errors.push(`assets[${id}] must be an object`);
139
+ return;
140
+ }
141
+ if (asset.id !== id) errors.push(`assets[${id}].id must match its key`);
142
+ if (!/^ASSET-[a-f0-9]{12,64}$/i.test(id)) errors.push(`assets[${id}] must use content-derived ASSET-<sha256-prefix> id`);
143
+ if (!KINDS.includes(asset.kind)) errors.push(`assets[${id}].kind is invalid`);
144
+ if (!Array.isArray(asset.tags) || asset.tags.length === 0 || asset.tags.some((tag) => typeof tag !== 'string' || !tag.trim())) errors.push(`assets[${id}].tags must contain at least one non-empty tag`);
145
+ if (!STATUSES.includes(asset.status)) errors.push(`assets[${id}].status is invalid`);
146
+ if (!APPROVALS.includes(asset.approval)) errors.push(`assets[${id}].approval is invalid`);
147
+ if (!asset.source || typeof asset.source !== 'object' || Array.isArray(asset.source)) {
148
+ errors.push(`assets[${id}].source must be an object`);
149
+ } else {
150
+ nonEmpty(asset.source.label, `assets[${id}].source.label`, errors);
151
+ nonEmpty(asset.source.author, `assets[${id}].source.author`, errors);
152
+ nonEmpty(asset.source.license, `assets[${id}].source.license`, errors);
153
+ }
154
+ if (!/^[a-f0-9]{64}$/i.test(asset.content_hash || '')) errors.push(`assets[${id}].content_hash must be a SHA-256 hex digest`);
155
+ try {
156
+ const filePath = safeAssetPath(versionDir, asset.path);
157
+ if (checkBytes && !existsSync(filePath)) errors.push(`assets[${id}].path does not exist: ${asset.path}`);
158
+ else if (checkBytes && hashFile(filePath) !== asset.content_hash) errors.push(`assets[${id}].content_hash does not match bytes at ${asset.path}`);
159
+ } catch (error) { errors.push(`assets[${id}].path invalid: ${error.message}`); }
160
+ if (asset.evidence_refs !== undefined && (!Array.isArray(asset.evidence_refs) || asset.evidence_refs.some((ref) => typeof ref !== 'string' || !ref.trim()))) {
161
+ errors.push(`assets[${id}].evidence_refs must be a string array`);
162
+ }
163
+ }
164
+
165
+ function validateEvidenceReciprocity(manifest, graph, errors) {
166
+ if (!graph) return;
167
+ for (const [id, asset] of Object.entries(manifest.assets || {})) {
168
+ for (const evidenceId of asset.evidence_refs || []) {
169
+ const node = graph.nodes[evidenceId];
170
+ if (!node || node.kind !== 'evidence') errors.push(`assets[${id}].evidence_refs references missing evidence node ${evidenceId}`);
171
+ else if (!(node.asset_refs || []).includes(id)) errors.push(`assets[${id}] -> ${evidenceId} lacks reciprocal evidence.asset_refs link`);
172
+ }
173
+ }
174
+ for (const node of Object.values(graph.nodes || {})) {
175
+ if (!node.asset_refs) continue;
176
+ if (node.kind !== 'evidence') errors.push(`${node.id}.asset_refs is only allowed on evidence nodes`);
177
+ for (const assetId of node.asset_refs) {
178
+ const asset = manifest.assets[assetId];
179
+ if (!asset) errors.push(`${node.id}.asset_refs references missing asset ${assetId}`);
180
+ else if (!(asset.evidence_refs || []).includes(node.id)) errors.push(`${node.id} -> ${assetId} lacks reciprocal asset.evidence_refs link`);
181
+ }
182
+ }
183
+ }
184
+
185
+ function validateProspectiveImport(versionDir, manifest, graph) {
186
+ const errors = [];
187
+ for (const [id, asset] of Object.entries(manifest.assets || {})) validateRecord(id, asset, versionDir, errors, { checkBytes: false });
188
+ if (graph) {
189
+ try { validateCapabilityGraph(graph); } catch (error) { errors.push(error.message); }
190
+ validateEvidenceReciprocity(manifest, graph, errors);
191
+ }
192
+ if (errors.length) throw new Error(`Asset import prevalidation failed:\n - ${errors.join('\n - ')}`);
193
+ }
194
+
195
+ export function validateAssetLibrary(versionDir, { checkGraph = true, recover = true } = {}) {
196
+ if (recover) recoverAssetImportTransaction(versionDir);
197
+ const manifestPath = getAssetManifestPath(versionDir);
198
+ if (!existsSync(manifestPath)) throw new Error(`missing ${ROOT_DIR}/${MANIFEST_FILE}`);
199
+ const manifest = readJsonFile(manifestPath, 'Asset Library manifest');
200
+ const errors = [];
201
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) errors.push('manifest must be an object');
202
+ if (!manifest.assets || typeof manifest.assets !== 'object' || Array.isArray(manifest.assets)) errors.push('manifest.assets must be an object');
203
+ else {
204
+ const seenHashes = new Set();
205
+ for (const [id, asset] of Object.entries(manifest.assets)) {
206
+ validateRecord(id, asset, versionDir, errors);
207
+ if (asset?.content_hash) {
208
+ if (seenHashes.has(asset.content_hash)) errors.push(`duplicate content_hash: ${asset.content_hash}`);
209
+ seenHashes.add(asset.content_hash);
210
+ }
211
+ }
212
+ }
213
+ if (checkGraph && manifest.assets && existsSync(getCapabilityGraphPath(versionDir))) {
214
+ try {
215
+ const graph = loadCapabilityGraph(versionDir);
216
+ validateEvidenceReciprocity(manifest, graph, errors);
217
+ } catch (error) { errors.push(`Capability Graph evidence reference check failed: ${error.message}`); }
218
+ }
219
+ if (errors.length) throw new Error(`Asset Library validation failed:\n - ${errors.join('\n - ')}`);
220
+ return manifest;
221
+ }
222
+
223
+ export function loadAssetLibrary(versionDir, options = {}) {
224
+ const { required = true, checkGraph = false } = options;
225
+ if (!existsSync(getAssetManifestPath(versionDir))) {
226
+ if (required) throw new Error(`missing ${ROOT_DIR}/${MANIFEST_FILE}`);
227
+ return null;
228
+ }
229
+ return validateAssetLibrary(versionDir, { checkGraph });
230
+ }
231
+
232
+ export function importAsset(versionDir, inputPath, metadata = {}) {
233
+ // Never start a new import on top of a previous interrupted one.
234
+ recoverAssetImportTransaction(versionDir);
235
+ if (!inputPath || typeof inputPath !== 'string') throw new Error('asset import requires an explicit local file path');
236
+ const sourcePath = resolve(inputPath);
237
+ if (!existsSync(sourcePath)) throw new Error(`source file does not exist: ${inputPath}`);
238
+ if (lstatSync(sourcePath).isSymbolicLink()) throw new Error('asset import rejects symbolic links; import the real local file explicitly');
239
+ if (!statSync(sourcePath).isFile()) throw new Error('asset import accepts files only');
240
+ const libraryRoot = resolve(getAssetLibraryDir(versionDir));
241
+ const sourceReal = realpathSync(sourcePath);
242
+ const sourceRel = relative(libraryRoot, sourceReal);
243
+ if (!sourceRel.startsWith('..') && !isAbsolute(sourceRel)) throw new Error('asset import rejects files already inside the library; use existing asset id instead');
244
+ const tags = normalizeTags(metadata.tags);
245
+ const kind = metadata.kind || 'image';
246
+ const approval = metadata.approval || 'pending';
247
+ const errors = [];
248
+ if (!KINDS.includes(kind)) errors.push(`invalid kind: ${kind}`);
249
+ if (!tags.length) errors.push('at least one --tags value is required');
250
+ if (!metadata.source) errors.push('--source is required');
251
+ if (!metadata.author) errors.push('--author is required');
252
+ if (!metadata.license) errors.push('--license is required');
253
+ if (approval !== 'approved') errors.push('import requires --approval approved; pending or rejected bytes cannot enter the usable library');
254
+ if (errors.length) throw new Error(errors.join('; '));
255
+
256
+ // The existing state must already be internally consistent before we create a
257
+ // candidate. This prevents a new import from laundering an old one-sided link.
258
+ const manifest = loadAssetLibrary(versionDir, { checkGraph: true });
259
+ const evidenceRefs = normalizeTags(metadata.evidenceRefs || []);
260
+ let graph = null;
261
+ if (evidenceRefs.length) {
262
+ graph = loadCapabilityGraph(versionDir);
263
+ for (const evidenceId of evidenceRefs) {
264
+ const node = graph.nodes[evidenceId];
265
+ if (!node || node.kind !== 'evidence') throw new Error(`--evidence references missing evidence node: ${evidenceId}`);
266
+ }
267
+ }
268
+ const contentHash = hashFile(sourceReal);
269
+ if (Object.values(manifest.assets).some((asset) => asset.content_hash === contentHash)) throw new Error(`duplicate asset bytes: SHA-256 ${contentHash} already exists in this library`);
270
+ const id = `ASSET-${contentHash.slice(0, 16)}`;
271
+ if (manifest.assets[id]) throw new Error(`duplicate asset id: ${id}`);
272
+ const suffix = extname(basename(sourceReal)).toLowerCase().replace(/[^.a-z0-9]/g, '');
273
+ const storedPath = `${FILES_DIR}/${id}${suffix || '.bin'}`;
274
+ const destination = safeAssetPath(versionDir, storedPath);
275
+ mkdirSync(getAssetFilesDir(versionDir), { recursive: true });
276
+ if (existsSync(destination)) throw new Error(`destination already exists: ${storedPath}`);
277
+ const asset = {
278
+ id,
279
+ kind,
280
+ tags,
281
+ status: 'active',
282
+ approval,
283
+ content_hash: contentHash,
284
+ path: storedPath,
285
+ source: { label: metadata.source, author: metadata.author, license: metadata.license },
286
+ original_name: basename(sourceReal),
287
+ evidence_refs: evidenceRefs,
288
+ };
289
+ const nextManifest = structuredClone(manifest);
290
+ const nextGraph = graph ? structuredClone(graph) : null;
291
+ nextManifest.assets[id] = asset;
292
+ if (nextGraph) {
293
+ for (const evidenceId of evidenceRefs) {
294
+ const node = nextGraph.nodes[evidenceId];
295
+ node.asset_refs = [...new Set([...(node.asset_refs || []), id])];
296
+ }
297
+ }
298
+
299
+ // Full preflight happens before a journal or any live file is touched.
300
+ validateProspectiveImport(versionDir, nextManifest, nextGraph);
301
+
302
+ const transactionId = `${id.slice(6).toLowerCase()}-${Date.now().toString(36)}`;
303
+ const temporaryAsset = transactionTempPath(destination, transactionId);
304
+ const manifestPath = getAssetManifestPath(versionDir);
305
+ const graphPath = getCapabilityGraphPath(versionDir);
306
+ const journal = {
307
+ schema: 'loom.asset-import-recovery.v1',
308
+ transaction_id: transactionId,
309
+ stage: 'prepared',
310
+ destination_path: storedPath,
311
+ temporary_asset_path: relative(getAssetLibraryDir(versionDir), temporaryAsset).replace(/\\/g, '/'),
312
+ before: { manifest: snapshot(manifestPath), graph: nextGraph ? snapshot(graphPath) : null },
313
+ };
314
+
315
+ try {
316
+ // Journal first. Each live-file replacement is same-directory temp + rename;
317
+ // the journal is what makes the three-file operation recoverable on failure.
318
+ writeImportJournal(versionDir, journal);
319
+ copyFileSync(sourceReal, temporaryAsset, 0);
320
+ fsyncFile(temporaryAsset);
321
+ if (hashFile(temporaryAsset) !== contentHash) throw new Error('copied asset hash mismatch; import aborted');
322
+
323
+ const manifestTemp = transactionTempPath(manifestPath, transactionId);
324
+ writeFileSync(manifestTemp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf-8');
325
+ fsyncFile(manifestTemp);
326
+ const graphTemp = nextGraph ? transactionTempPath(graphPath, transactionId) : null;
327
+ if (graphTemp) {
328
+ writeFileSync(graphTemp, `${JSON.stringify(nextGraph, null, 2)}\n`, 'utf-8');
329
+ fsyncFile(graphTemp);
330
+ }
331
+ journal.stage = 'staged';
332
+ writeImportJournal(versionDir, journal);
333
+
334
+ renameSync(temporaryAsset, destination);
335
+ journal.stage = 'asset_committed';
336
+ writeImportJournal(versionDir, journal);
337
+
338
+ renameSync(manifestTemp, manifestPath);
339
+ journal.stage = 'manifest_committed';
340
+ writeImportJournal(versionDir, journal);
341
+ if (metadata.failureInjection === 'after_manifest') throw new Error('injected asset import failure after manifest write');
342
+ if (metadata.failureInjection === 'crash_after_manifest') {
343
+ // Test-only crash seam: leave the durable journal in place so the next
344
+ // validate/startup path proves it can restore a half-committed import.
345
+ throw Object.assign(new Error('injected crash after manifest write'), { skipAssetImportRollback: true });
346
+ }
347
+
348
+ if (graphTemp) {
349
+ renameSync(graphTemp, graphPath);
350
+ journal.stage = 'graph_committed';
351
+ writeImportJournal(versionDir, journal);
352
+ }
353
+
354
+ // Validate the fully committed candidate before discarding its recovery record.
355
+ validateAssetLibrary(versionDir, { checkGraph: true, recover: false });
356
+ removeIfExists(getAssetImportJournalPath(versionDir));
357
+ return asset;
358
+ } catch (error) {
359
+ if (error.skipAssetImportRollback) throw error;
360
+ try { recoverAssetImportTransaction(versionDir); } catch (recoveryError) {
361
+ throw new Error(`${error.message}; rollback also failed: ${recoveryError.message}`);
362
+ }
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ export function listAssets(versionDir) {
368
+ const manifest = loadAssetLibrary(versionDir, { checkGraph: false });
369
+ return Object.values(manifest.assets).map((asset) => ({ ...asset }));
370
+ }
371
+
372
+ export function searchAssets(versionDir, query, { approvedOnly = true } = {}) {
373
+ const needle = String(query || '').trim().toLocaleLowerCase();
374
+ if (!needle) throw new Error('asset search requires a non-empty query');
375
+ return listAssets(versionDir).filter((asset) => (!approvedOnly || (asset.approval === 'approved' && asset.status === 'active'))
376
+ && [asset.id, asset.kind, ...asset.tags, asset.original_name]
377
+ .some((value) => String(value || '').toLocaleLowerCase().includes(needle)));
378
+ }
379
+
380
+ export function getAsset(versionDir, id) {
381
+ const asset = loadAssetLibrary(versionDir, { checkGraph: false }).assets[id];
382
+ if (!asset) throw new Error(`asset not found: ${id}`);
383
+ return asset;
384
+ }