@haaaiawd/loom 1.1.0 → 1.2.1

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.
@@ -11,6 +11,7 @@ const NODE_KINDS = ['outcome', 'concern', 'capability', 'risk', 'evidence'];
11
11
  const NODE_STATUSES = ['open', 'researched', 'covered', 'deferred', 'out_of_scope'];
12
12
  const ROUTES = ['expand', 'brief', 'intent', 'defer', 'exclude', 'covered_by'];
13
13
  const RELATIONSHIPS = ['refines', 'requires', 'realizes', 'constrains', 'risks', 'validated_by', 'covered_by'];
14
+ const ACQUISITION_MODES = ['adaptive', 'external_required', 'project_only'];
14
15
  const VERIFICATION_FIELDS = ['method', 'target', 'procedure', 'pass_criteria', 'artifact'];
15
16
  const EVIDENCE_ARTIFACT_DIRS = ['verifications', '08_ASSET_LIBRARY/files'];
16
17
 
@@ -66,6 +67,21 @@ function validateNode(id, node, allIds, errors) {
66
67
  if (node.asset_refs !== undefined && node.kind !== 'evidence') {
67
68
  errors.push(`nodes["${id}"].asset_refs 只允许写在 evidence 节点`);
68
69
  }
70
+ if (node.acquisition_mode !== undefined) {
71
+ if (node.kind !== 'capability') {
72
+ errors.push(`nodes["${id}"].acquisition_mode 只允许写在 capability 节点`);
73
+ } else if (!ACQUISITION_MODES.includes(node.acquisition_mode)) {
74
+ errors.push(`nodes["${id}"].acquisition_mode 非法: ${node.acquisition_mode}`);
75
+ }
76
+ }
77
+ if (node.acquisition_mode === 'project_only'
78
+ && (typeof node.acquisition_rationale !== 'string' || !node.acquisition_rationale.trim())) {
79
+ errors.push(`nodes["${id}"].acquisition_mode=project_only 必须声明 acquisition_rationale`);
80
+ }
81
+ if (node.impact === 'high' && node.acquisition_mode === 'adaptive'
82
+ && (typeof node.acquisition_rationale !== 'string' || !node.acquisition_rationale.trim())) {
83
+ errors.push(`nodes["${id}"] 为高影响 capability 且选择 adaptive 时必须声明 acquisition_rationale;说明为何此处不启用 external_required`);
84
+ }
69
85
  if (node.verification !== undefined) {
70
86
  if (node.kind !== 'evidence') {
71
87
  errors.push(`nodes["${id}"].verification 只允许写在 evidence 节点`);
@@ -131,6 +147,13 @@ export function loadCapabilityGraph(versionDir, { required = true } = {}) {
131
147
  return validateCapabilityGraph(readJsonFile(filePath, 'Capability Graph'));
132
148
  }
133
149
 
150
+ function getEffectiveAcquisitionMode(node) {
151
+ if (node.kind !== 'capability') return null;
152
+ if (node.acquisition_mode) return node.acquisition_mode;
153
+ if (node.impact === 'high') return 'external_required';
154
+ return 'adaptive';
155
+ }
156
+
134
157
  function isRouted(node) {
135
158
  return ['brief', 'intent', 'defer', 'exclude', 'covered_by'].includes(node.route);
136
159
  }
@@ -248,6 +271,7 @@ export function getCapabilityCoverage(versionDir) {
248
271
  const capabilitiesWithoutPlan = [];
249
272
  const routingGaps = [];
250
273
  const outcomesWithoutConcern = [];
274
+ const evidenceArtifactGaps = [];
251
275
 
252
276
  for (const node of nodes) {
253
277
  for (const intentId of node.intent_refs || []) {
@@ -261,6 +285,19 @@ export function getCapabilityCoverage(versionDir) {
261
285
  if (node.route === 'brief' && !node.brief_ref) {
262
286
  capabilitiesWithoutPlan.push({ node_id: node.id, reason: 'route=brief 但缺少 brief_ref' });
263
287
  }
288
+ if (node.kind === 'capability') {
289
+ const linkedIntents = (node.intent_refs || [])
290
+ .map((intentId) => intentMap.intents[intentId])
291
+ .filter(Boolean);
292
+ const needsExternalAcquisition = linkedIntents.length > 0
293
+ && getEffectiveAcquisitionMode(node) === 'external_required';
294
+ if (needsExternalAcquisition && !node.brief_ref && node.route !== 'brief') {
295
+ capabilitiesWithoutPlan.push({
296
+ node_id: node.id,
297
+ reason: '外部能力获取为 required,但缺少 brief_ref 来定义专业问题与验收边界',
298
+ });
299
+ }
300
+ }
264
301
  if (['defer', 'exclude'].includes(node.route) && (!node.rationale || typeof node.rationale !== 'string' || node.rationale.trim() === '')) {
265
302
  routingGaps.push({ node_id: node.id, reason: `route=${node.route} 但缺少 rationale` });
266
303
  }
@@ -273,6 +310,12 @@ export function getCapabilityCoverage(versionDir) {
273
310
  if (node.kind === 'outcome' && !(node.relationships || []).some((relation) => graph.nodes[relation.target]?.kind === 'concern')) {
274
311
  outcomesWithoutConcern.push({ node_id: node.id, reason: 'outcome 没有连接到 concern,项目初衷尚未展开为问题面' });
275
312
  }
313
+ if (node.kind === 'evidence' && node.verification?.artifact) {
314
+ const ownedCompletedIntent = (node.intent_refs || []).some((intentId) => intentMap.intents[intentId]?.status === 'completed');
315
+ if (ownedCompletedIntent && !resolveEvidenceArtifact(versionDir, node.verification.artifact)) {
316
+ evidenceArtifactGaps.push({ node_id: node.id, reason: `已完成 Intent 的 evidence artifact 不存在或不可读: ${node.verification.artifact}` });
317
+ }
318
+ }
276
319
  if (node.brief_ref) {
277
320
  try { resolveBrief(versionDir, node.brief_ref); } catch (error) {
278
321
  capabilitiesWithoutPlan.push({ node_id: node.id, reason: error.message });
@@ -294,6 +337,7 @@ export function getCapabilityCoverage(versionDir) {
294
337
  routing_gaps: routingGaps.length,
295
338
  outcomes_without_concern: outcomesWithoutConcern.length,
296
339
  high_outcomes_without_observable_evidence: highOutcomesWithoutObservableEvidence.length,
340
+ evidence_artifact_gaps: evidenceArtifactGaps.length,
297
341
  intent_mapping_required: intentMappingRequired,
298
342
  unmapped_intents: unmappedIntents.length,
299
343
  ready: highUnrouted.length === 0
@@ -302,6 +346,7 @@ export function getCapabilityCoverage(versionDir) {
302
346
  && routingGaps.length === 0
303
347
  && outcomesWithoutConcern.length === 0
304
348
  && highOutcomesWithoutObservableEvidence.length === 0
349
+ && evidenceArtifactGaps.length === 0
305
350
  && unmappedIntents.length === 0,
306
351
  },
307
352
  high_unrouted: highUnrouted,
@@ -310,6 +355,7 @@ export function getCapabilityCoverage(versionDir) {
310
355
  routing_gaps: routingGaps,
311
356
  outcomes_without_concern: outcomesWithoutConcern,
312
357
  high_outcomes_without_observable_evidence: highOutcomesWithoutObservableEvidence,
358
+ evidence_artifact_gaps: evidenceArtifactGaps,
313
359
  unmapped_intents: unmappedIntents,
314
360
  };
315
361
  }
@@ -339,7 +385,15 @@ function collectCompilationNodes(graph, intentId) {
339
385
 
340
386
  export function compileCapabilityInputs(versionDir, intentId) {
341
387
  const graph = loadCapabilityGraph(versionDir, { required: false });
342
- if (!graph) return { available: false, nodes: [], briefs: [], warnings: ['项目尚未建立 Capability Graph;使用 Intent 的 capability_needs 兼容路径。'] };
388
+ if (!graph) {
389
+ return {
390
+ available: false,
391
+ nodes: [],
392
+ briefs: [],
393
+ warnings: ['项目尚未建立 Capability Graph;使用 Intent 的 capability_needs 兼容路径。'],
394
+ acquisition: { required: false, required_node_ids: [], nodes: [] },
395
+ };
396
+ }
343
397
  const nodes = collectCompilationNodes(graph, intentId);
344
398
  const briefs = [];
345
399
  const warnings = [];
@@ -347,5 +401,30 @@ export function compileCapabilityInputs(versionDir, intentId) {
347
401
  if (!node.brief_ref) continue;
348
402
  try { briefs.push({ node_id: node.id, ...resolveBrief(versionDir, node.brief_ref) }); } catch (error) { warnings.push(`${node.id}: ${error.message}`); }
349
403
  }
350
- return { available: true, nodes, briefs, warnings };
404
+ const acquisitionNodes = nodes
405
+ .filter((node) => node.kind === 'capability')
406
+ .map((node) => ({
407
+ node_id: node.id,
408
+ title: node.title,
409
+ mode: getEffectiveAcquisitionMode(node),
410
+ reason: node.acquisition_mode
411
+ ? 'Capability Graph 显式声明'
412
+ : node.impact === 'high'
413
+ ? '高影响能力默认需要外部来源化'
414
+ : '按任务证据自适应判断',
415
+ }));
416
+ const requiredNodeIds = acquisitionNodes
417
+ .filter((node) => node.mode === 'external_required')
418
+ .map((node) => node.node_id);
419
+ return {
420
+ available: true,
421
+ nodes,
422
+ briefs,
423
+ warnings,
424
+ acquisition: {
425
+ required: requiredNodeIds.length > 0,
426
+ required_node_ids: requiredNodeIds,
427
+ nodes: acquisitionNodes,
428
+ },
429
+ };
351
430
  }
@@ -15,6 +15,7 @@ import { getCapabilityCoverage, getCapabilityGraphPath, loadCapabilityGraph } fr
15
15
  import { listCapabilityProposals } from './capability-proposals.js';
16
16
  import { getAssetManifestPath, validateAssetLibrary } from './asset-library.js';
17
17
  import { validateAtelierRecord } from './atelier.js';
18
+ import { getExpertisePackState } from './expertise-pack.js';
18
19
 
19
20
  function readIntentMapRaw(versionDir) {
20
21
  const filePath = join(versionDir, '04_INTENT_MAP.json');
@@ -178,6 +179,8 @@ const FIX_HINTS = {
178
179
  asset_library_invalid: '修复 08_ASSET_LIBRARY/manifest.json 的来源、许可、哈希、库内路径或 evidence 双向引用,然后运行 loom asset validate。',
179
180
  atelier_record_invalid: '运行 loom atelier init {id} 创建记录,或按校验错误修正后运行 loom atelier validate {id}。',
180
181
  atelier_verification_missing: '重新运行独立 Keeper 验证,让 passed 记录绑定当前 Atelier Record 与 stance_revision。',
182
+ expertise_pack_invalid: '运行 loom expertise init {id}(若尚未创建);实际执行 find skill / 网络或文档检索,补齐来源与 Capability Capsules 后运行 loom expertise validate {id}。',
183
+ expertise_verification_missing: '重新运行独立 Keeper 验证,让 passed 记录绑定当前 10_EXPERTISE_PACKS/{id}.json。',
181
184
  };
182
185
 
183
186
  /**
@@ -271,6 +274,9 @@ export function doctor(versionDir, verificationsDir, philosophyDir) {
271
274
  for (const item of coverage.high_outcomes_without_observable_evidence) {
272
275
  issues.push({ id: item.node_id, type: 'capability_outcome_unobservable', severity: 'high', msg: `${item.node_id} 缺少真实呈现或交付的验证入口: ${item.reason}` });
273
276
  }
277
+ for (const item of coverage.evidence_artifact_gaps || []) {
278
+ issues.push({ id: item.node_id, type: 'capability_evidence_artifact_missing', severity: 'high', msg: `${item.node_id} 的完成证据不可用: ${item.reason}` });
279
+ }
274
280
  for (const item of coverage.orphan_intent_refs) {
275
281
  issues.push({ id: item.node_id, type: 'intent_graph_unmapped', severity: 'high', msg: `${item.node_id} 引用了不存在的 Intent: ${item.intent_id}` });
276
282
  }
@@ -314,6 +320,33 @@ export function doctor(versionDir, verificationsDir, philosophyDir) {
314
320
  }
315
321
  }
316
322
  }
323
+ if (!['pending', 'blocked', 'deprecated'].includes(intent.status)) {
324
+ try {
325
+ const expertise = getExpertisePackState(versionDir, id);
326
+ if (expertise.required && !expertise.ready) {
327
+ issues.push({
328
+ id,
329
+ type: 'expertise_pack_invalid',
330
+ severity: intent.status === 'completed' || latest?.verdict === 'passed' ? 'high' : 'medium',
331
+ msg: `${id} 的外部能力获取强门尚未闭合: ${expertise.reason || 'Expertise Pack not ready'}`,
332
+ });
333
+ } else if (expertise.required && latest?.verdict === 'passed') {
334
+ const validation = expertise.validation;
335
+ if (latest.expertise?.record_ref !== `10_EXPERTISE_PACKS/${id}.json`
336
+ || latest.expertise?.intent_revision !== validation.intent_revision
337
+ || latest.expertise?.source_count !== validation.source_count
338
+ || latest.expertise?.capsule_count !== validation.capsule_count
339
+ || latest.expertise?.pack_digest !== validation.pack_digest) {
340
+ issues.push({
341
+ id,
342
+ type: 'expertise_verification_missing',
343
+ severity: 'high',
344
+ msg: `${id} 的最新 passed 未绑定当前 Expertise Pack、Intent revision 与来源/Capsule 计数`,
345
+ });
346
+ }
347
+ }
348
+ } catch { /* Capability Graph 的结构错误已由上方统一报告。 */ }
349
+ }
317
350
  if (intent.quality_strategy === 'atelier' && latest?.verdict === 'passed') {
318
351
  const atelier = atelierRecords.get(id);
319
352
  if (!atelier
@@ -0,0 +1,336 @@
1
+ // expertise-pack.js — provenance-backed external capability acquisition for one Intent.
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { getIntent } from './intent-map.js';
7
+ import { compileCapabilityInputs } from './capability-graph.js';
8
+ import { getLoomRoot } from './shared/paths.js';
9
+ import { readJsonFile } from './shared/md-utils.js';
10
+
11
+ const PACKS_DIR = '10_EXPERTISE_PACKS';
12
+ const VALID_STATUS = ['draft', 'ready', 'blocked'];
13
+ const QUERY_CHANNELS = ['skill_registry', 'web', 'official_docs', 'research', 'tool', 'asset'];
14
+ const SOURCE_KINDS = ['skill', 'web', 'official_docs', 'research', 'tool', 'human'];
15
+ const SOURCE_AUTHORITIES = ['official', 'primary', 'expert', 'community', 'secondary'];
16
+ const EXTERNAL_KNOWLEDGE_KINDS = new Set(['skill', 'web', 'official_docs', 'research']);
17
+
18
+ export function getExpertisePacksDir(versionDir) {
19
+ return join(versionDir, PACKS_DIR);
20
+ }
21
+
22
+ export function getExpertisePackPath(versionDir, intentId) {
23
+ return join(getExpertisePacksDir(versionDir), `${intentId}.json`);
24
+ }
25
+
26
+ function pushText(value, label, errors) {
27
+ if (typeof value !== 'string' || value.trim() === '') errors.push(`${label} 必须是非空字符串`);
28
+ }
29
+
30
+ function pushTextArray(value, label, errors, { min = 1 } = {}) {
31
+ if (!Array.isArray(value)) {
32
+ errors.push(`${label} 必须是字符串数组`);
33
+ return;
34
+ }
35
+ if (value.length < min) errors.push(`${label} 至少需要 ${min} 项`);
36
+ value.forEach((item, index) => pushText(item, `${label}[${index}]`, errors));
37
+ }
38
+
39
+ function isHttpsLocator(value) {
40
+ try {
41
+ return new URL(value).protocol === 'https:';
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function getExpertiseRequirement(versionDir, intentId) {
48
+ const intent = getIntent(versionDir, intentId);
49
+ const compiled = compileCapabilityInputs(versionDir, intentId);
50
+ const acquisition = compiled.acquisition || {
51
+ required: false,
52
+ required_node_ids: [],
53
+ nodes: [],
54
+ };
55
+ return {
56
+ intent,
57
+ compiled,
58
+ required: acquisition.required === true,
59
+ required_node_ids: acquisition.required_node_ids || [],
60
+ acquisition_nodes: acquisition.nodes || [],
61
+ };
62
+ }
63
+
64
+ function validateSearchPlan(plan, strict, errors) {
65
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
66
+ errors.push('search_plan 必须是对象');
67
+ return;
68
+ }
69
+ if (!strict) return;
70
+ pushText(plan.decision_question, 'search_plan.decision_question', errors);
71
+ pushTextArray(plan.project_signals, 'search_plan.project_signals', errors);
72
+ pushTextArray(plan.constraints, 'search_plan.constraints', errors);
73
+ pushText(plan.stop_condition, 'search_plan.stop_condition', errors);
74
+ if (!Array.isArray(plan.derived_queries) || plan.derived_queries.length === 0) {
75
+ errors.push('search_plan.derived_queries 至少需要一条运行时派生查询');
76
+ } else {
77
+ plan.derived_queries.forEach((query, index) => {
78
+ const prefix = `search_plan.derived_queries[${index}]`;
79
+ if (!query || typeof query !== 'object' || Array.isArray(query)) {
80
+ errors.push(`${prefix} 必须是对象`);
81
+ return;
82
+ }
83
+ if (!QUERY_CHANNELS.includes(query.channel)) {
84
+ errors.push(`${prefix}.channel 必须是 ${QUERY_CHANNELS.join('|')}`);
85
+ }
86
+ pushText(query.query, `${prefix}.query`, errors);
87
+ pushText(query.rationale, `${prefix}.rationale`, errors);
88
+ });
89
+ }
90
+ }
91
+
92
+ function validateSources(sources, strict, errors) {
93
+ const ids = new Set();
94
+ const valid = new Map();
95
+ if (!Array.isArray(sources)) {
96
+ errors.push('sources 必须是数组');
97
+ return { ids, valid, externalCount: 0 };
98
+ }
99
+ if (strict && sources.length === 0) errors.push('ready Expertise Pack 必须包含外部来源');
100
+ let externalCount = 0;
101
+ sources.forEach((source, index) => {
102
+ const prefix = `sources[${index}]`;
103
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
104
+ errors.push(`${prefix} 必须是对象`);
105
+ return;
106
+ }
107
+ pushText(source.id, `${prefix}.id`, errors);
108
+ if (typeof source.id === 'string') {
109
+ if (ids.has(source.id)) errors.push(`${prefix}.id 重复: ${source.id}`);
110
+ ids.add(source.id);
111
+ }
112
+ if (!SOURCE_KINDS.includes(source.kind)) {
113
+ errors.push(`${prefix}.kind 必须是 ${SOURCE_KINDS.join('|')}`);
114
+ }
115
+ if (!SOURCE_AUTHORITIES.includes(source.authority)) {
116
+ errors.push(`${prefix}.authority 必须是 ${SOURCE_AUTHORITIES.join('|')}`);
117
+ }
118
+ pushText(source.title, `${prefix}.title`, errors);
119
+ pushText(source.locator, `${prefix}.locator`, errors);
120
+ if (source.kind !== 'human' && typeof source.locator === 'string' && !isHttpsLocator(source.locator)) {
121
+ errors.push(`${prefix}.locator 必须是可追溯的 https URL`);
122
+ }
123
+ pushText(source.retrieved_at, `${prefix}.retrieved_at`, errors);
124
+ if (typeof source.retrieved_at === 'string' && Number.isNaN(Date.parse(source.retrieved_at))) {
125
+ errors.push(`${prefix}.retrieved_at 必须是合法日期时间`);
126
+ }
127
+ pushText(source.why_selected, `${prefix}.why_selected`, errors);
128
+ pushText(source.retrieval_evidence, `${prefix}.retrieval_evidence`, errors);
129
+ if (EXTERNAL_KNOWLEDGE_KINDS.has(source.kind)) externalCount += 1;
130
+ if (typeof source.id === 'string' && source.id.trim()) valid.set(source.id, source);
131
+ });
132
+ if (strict && externalCount === 0) {
133
+ errors.push('ready Expertise Pack 至少需要一个 skill|web|official_docs|research 外部知识来源');
134
+ }
135
+ return { ids, valid, externalCount };
136
+ }
137
+
138
+ function validateCapsules(capsules, requiredNodeIds, sourceMap, strict, errors) {
139
+ const covered = new Set();
140
+ if (!Array.isArray(capsules)) {
141
+ errors.push('capsules 必须是数组');
142
+ return covered;
143
+ }
144
+ if (strict && capsules.length === 0) errors.push('ready Expertise Pack 必须包含 Capability Capsule');
145
+ capsules.forEach((capsule, index) => {
146
+ const prefix = `capsules[${index}]`;
147
+ if (!capsule || typeof capsule !== 'object' || Array.isArray(capsule)) {
148
+ errors.push(`${prefix} 必须是对象`);
149
+ return;
150
+ }
151
+ pushText(capsule.capability_ref, `${prefix}.capability_ref`, errors);
152
+ if (typeof capsule.capability_ref === 'string') covered.add(capsule.capability_ref);
153
+ if (strict && requiredNodeIds.length > 0 && !requiredNodeIds.includes(capsule.capability_ref)) {
154
+ errors.push(`${prefix}.capability_ref 未引用当前 Intent 的外部获取能力节点: ${capsule.capability_ref}`);
155
+ }
156
+ if (!strict) return;
157
+ pushText(capsule.professional_problem, `${prefix}.professional_problem`, errors);
158
+ pushText(capsule.when_to_use, `${prefix}.when_to_use`, errors);
159
+ pushTextArray(capsule.rules, `${prefix}.rules`, errors);
160
+ pushTextArray(capsule.workflow, `${prefix}.workflow`, errors);
161
+ pushTextArray(capsule.decision_gates, `${prefix}.decision_gates`, errors);
162
+ pushTextArray(capsule.failure_modes, `${prefix}.failure_modes`, errors);
163
+ pushTextArray(capsule.verification_signals, `${prefix}.verification_signals`, errors);
164
+ if (!Array.isArray(capsule.source_refs) || capsule.source_refs.length === 0) {
165
+ errors.push(`${prefix}.source_refs 至少需要一个来源`);
166
+ } else {
167
+ let externalRefCount = 0;
168
+ capsule.source_refs.forEach((ref, refIndex) => {
169
+ pushText(ref, `${prefix}.source_refs[${refIndex}]`, errors);
170
+ if (typeof ref === 'string' && !sourceMap.has(ref)) {
171
+ errors.push(`${prefix}.source_refs[${refIndex}] 引用不存在的来源: ${ref}`);
172
+ } else if (EXTERNAL_KNOWLEDGE_KINDS.has(sourceMap.get(ref)?.kind)) {
173
+ externalRefCount += 1;
174
+ }
175
+ });
176
+ if (externalRefCount === 0) {
177
+ errors.push(`${prefix} 必须直接引用至少一个 skill|web|official_docs|research 外部知识来源`);
178
+ }
179
+ }
180
+ });
181
+ if (strict) {
182
+ for (const nodeId of requiredNodeIds) {
183
+ if (!covered.has(nodeId)) errors.push(`缺少外部必需能力 ${nodeId} 的 Capability Capsule`);
184
+ }
185
+ }
186
+ return covered;
187
+ }
188
+
189
+ export function validateExpertisePack(versionDir, intentId, pack = null, { requireReady = true } = {}) {
190
+ const requirement = getExpertiseRequirement(versionDir, intentId);
191
+ const path = getExpertisePackPath(versionDir, intentId);
192
+ const data = pack ?? readJsonFile(path, 'Expertise Pack');
193
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
194
+ throw new Error('Expertise Pack 校验失败:\n - 根节点必须是对象');
195
+ }
196
+ const errors = [];
197
+ if (!data._meta || typeof data._meta !== 'object' || Array.isArray(data._meta)) {
198
+ errors.push('_meta 必须是对象');
199
+ } else {
200
+ if (data._meta._version !== '1.0') errors.push('_meta._version 必须是 1.0');
201
+ if (data._meta._loom_version !== basename(versionDir)) {
202
+ errors.push(`_meta._loom_version 必须是 ${basename(versionDir)}`);
203
+ }
204
+ }
205
+ if (data.intent_id !== intentId) errors.push(`intent_id 必须是 ${intentId}`);
206
+ if (data.intent_revision !== (requirement.intent.revision ?? 1)) {
207
+ errors.push(`intent_revision=${data.intent_revision} 已过期;当前为 ${requirement.intent.revision ?? 1}`);
208
+ }
209
+ if (!Array.isArray(data.required_capability_refs)) {
210
+ errors.push('required_capability_refs 必须是数组');
211
+ } else {
212
+ data.required_capability_refs.forEach((ref, index) => {
213
+ pushText(ref, `required_capability_refs[${index}]`, errors);
214
+ });
215
+ const declared = [...new Set(data.required_capability_refs)].sort();
216
+ const expected = [...new Set(requirement.required_node_ids)].sort();
217
+ if (JSON.stringify(declared) !== JSON.stringify(expected)) {
218
+ errors.push(`required_capability_refs 必须精确匹配当前强门节点: ${expected.join(', ') || '无'}`);
219
+ }
220
+ }
221
+ if (!VALID_STATUS.includes(data.status)) errors.push(`status 必须是 ${VALID_STATUS.join('|')}`);
222
+ const strict = data.status === 'ready';
223
+ validateSearchPlan(data.search_plan, data.status === 'ready' || data.status === 'blocked', errors);
224
+ const sourceResult = validateSources(data.sources, strict, errors);
225
+ validateCapsules(data.capsules, requirement.required_node_ids, sourceResult.valid, strict, errors);
226
+ if (data.status === 'blocked') {
227
+ if (!data.blocker || typeof data.blocker !== 'object' || Array.isArray(data.blocker)) {
228
+ errors.push('blocked 状态必须提供 blocker 对象');
229
+ } else {
230
+ pushText(data.blocker.reason, 'blocker.reason', errors);
231
+ pushText(data.blocker.recovery_condition, 'blocker.recovery_condition', errors);
232
+ }
233
+ } else if (data.blocker !== null && data.blocker !== undefined) {
234
+ errors.push('非 blocked 状态的 blocker 必须为 null 或省略');
235
+ }
236
+ if (requireReady && requirement.required && data.status !== 'ready') {
237
+ errors.push(`当前 Intent 需要外部能力获取,Expertise Pack 必须为 ready(当前: ${data.status})`);
238
+ }
239
+ if (errors.length) throw new Error(`Expertise Pack 校验失败:\n - ${errors.join('\n - ')}`);
240
+ return {
241
+ valid: true,
242
+ required: requirement.required,
243
+ intent_id: intentId,
244
+ intent_revision: data.intent_revision,
245
+ status: data.status,
246
+ required_node_ids: requirement.required_node_ids,
247
+ source_count: data.sources.length,
248
+ external_source_count: sourceResult.externalCount,
249
+ capsule_count: data.capsules.length,
250
+ pack_digest: createHash('sha256').update(JSON.stringify(data)).digest('hex'),
251
+ path,
252
+ };
253
+ }
254
+
255
+ export function getExpertisePack(versionDir, intentId) {
256
+ const path = getExpertisePackPath(versionDir, intentId);
257
+ if (!existsSync(path)) throw new Error(`Expertise Pack 不存在: ${path}`);
258
+ const pack = readJsonFile(path, 'Expertise Pack');
259
+ validateExpertisePack(versionDir, intentId, pack, { requireReady: false });
260
+ return pack;
261
+ }
262
+
263
+ export function initExpertisePack(versionDir, intentId) {
264
+ const requirement = getExpertiseRequirement(versionDir, intentId);
265
+ if (!requirement.required) {
266
+ throw new Error(`${intentId} 当前没有外部能力获取强门;只有显式 external_required 或未显式豁免的高影响 capability 才需要持久化 Expertise Pack`);
267
+ }
268
+ const path = getExpertisePackPath(versionDir, intentId);
269
+ if (existsSync(path)) throw new Error(`Expertise Pack 已存在,不会覆盖: ${path}`);
270
+ const templatePath = join(getLoomRoot(), 'templates', 'EXPERTISE_PACK_TEMPLATE.json');
271
+ const pack = JSON.parse(readFileSync(templatePath, 'utf-8'));
272
+ pack._meta._loom_version = basename(versionDir);
273
+ pack.intent_id = intentId;
274
+ pack.intent_revision = requirement.intent.revision ?? 1;
275
+ pack.required_capability_refs = requirement.required_node_ids;
276
+ mkdirSync(getExpertisePacksDir(versionDir), { recursive: true });
277
+ writeFileSync(path, `${JSON.stringify(pack, null, 2)}\n`, { encoding: 'utf-8', flag: 'wx' });
278
+ return pack;
279
+ }
280
+
281
+ export function getExpertisePackState(versionDir, intentId) {
282
+ const requirement = getExpertiseRequirement(versionDir, intentId);
283
+ const path = getExpertisePackPath(versionDir, intentId);
284
+ if (!requirement.required) {
285
+ return { required: false, ready: true, path, required_node_ids: [] };
286
+ }
287
+ if (!existsSync(path)) {
288
+ return { required: true, ready: false, path, required_node_ids: requirement.required_node_ids, reason: 'missing' };
289
+ }
290
+ try {
291
+ const validation = validateExpertisePack(versionDir, intentId, null, { requireReady: false });
292
+ const ready = validation.status === 'ready';
293
+ const pack = ready ? null : readJsonFile(path, 'Expertise Pack');
294
+ const reason = ready
295
+ ? undefined
296
+ : validation.status === 'blocked'
297
+ ? `blocked: ${pack.blocker.reason}`
298
+ : `status=${validation.status}`;
299
+ return { required: true, ready, path, required_node_ids: requirement.required_node_ids, validation, reason };
300
+ } catch (error) {
301
+ return { required: true, ready: false, path, required_node_ids: requirement.required_node_ids, reason: error.message };
302
+ }
303
+ }
304
+
305
+ export function assertExpertiseReady(versionDir, intentId) {
306
+ const state = getExpertisePackState(versionDir, intentId);
307
+ if (!state.required) return null;
308
+ if (!state.ready) {
309
+ const action = state.reason === 'missing'
310
+ ? `先运行 loom expertise init ${intentId},通过 find skill 与网络搜索获取外部信息,完成后运行 loom expertise validate ${intentId}`
311
+ : `修正 10_EXPERTISE_PACKS/${intentId}.json 后运行 loom expertise validate ${intentId}`;
312
+ throw new Error(`${intentId} 的外部能力获取强门尚未闭合: ${state.reason || 'Expertise Pack not ready'}\n${action}`);
313
+ }
314
+ return state.validation;
315
+ }
316
+
317
+ export function formatExpertisePackForPrompt(pack) {
318
+ const lines = [
319
+ `- decision_question: ${pack.search_plan.decision_question}`,
320
+ `- sources: ${pack.sources.map((source) => `${source.id} ${source.title} (${source.locator})`).join('; ')}`,
321
+ ];
322
+ for (const capsule of pack.capsules) {
323
+ lines.push(
324
+ `\n### Capability Capsule: ${capsule.capability_ref}`,
325
+ `- professional_problem: ${capsule.professional_problem}`,
326
+ `- when_to_use: ${capsule.when_to_use}`,
327
+ `- rules:\n${capsule.rules.map((item) => ` - ${item}`).join('\n')}`,
328
+ `- workflow:\n${capsule.workflow.map((item) => ` - ${item}`).join('\n')}`,
329
+ `- decision_gates:\n${capsule.decision_gates.map((item) => ` - ${item}`).join('\n')}`,
330
+ `- failure_modes:\n${capsule.failure_modes.map((item) => ` - ${item}`).join('\n')}`,
331
+ `- verification_signals:\n${capsule.verification_signals.map((item) => ` - ${item}`).join('\n')}`,
332
+ `- source_refs: ${capsule.source_refs.join(', ')}`,
333
+ );
334
+ }
335
+ return lines.join('\n');
336
+ }
package/cli/src/guide.js CHANGED
@@ -10,6 +10,7 @@ import { getCapabilityCoverage } from './capability-graph.js';
10
10
  import { listCapabilityProposals } from './capability-proposals.js';
11
11
  import { isAutoOn, getAutoMode, writeHeartbeat, needsHumanReview } from './auto.js';
12
12
  import { doctor } from './diagnostics.js';
13
+ import { getExpertisePackState } from './expertise-pack.js';
13
14
 
14
15
  /**
15
16
  * 检测文件是否还是模板(未填充真实内容)。
@@ -145,7 +146,7 @@ export function guideProject(projectDir, options = {}) {
145
146
  }
146
147
  } else if (result.stage_num >= 4) {
147
148
  if (auto) {
148
- result.message += '\n\n> AUTO 模式开启——直接执行 next_command,无需人类确认。';
149
+ result.message += '\n\n> AUTO 模式开启——可继续进入下一阶段;契约、证据与 Keeper 门禁仍不会被跳过。';
149
150
  } else {
150
151
  result.message += '\n\n> ⚠ AUTO 模式关闭——执行 next_command 后等人类确认再继续。';
151
152
  }
@@ -373,6 +374,39 @@ function diagnoseStage(cwd, loomRoot, auto) {
373
374
  // 状态 5: 有 in_progress
374
375
  if (counts.in_progress > 0) {
375
376
  const inProgressIds = allIntents.filter((i) => i.status === 'in_progress').map((i) => i.id);
377
+ const expertiseOpen = allIntents
378
+ .filter((intent) => intent.status === 'in_progress')
379
+ .map((intent) => ({ intent, state: getExpertisePackState(versionDir, intent.id) }))
380
+ .find(({ state }) => state.required && !state.ready);
381
+ if (expertiseOpen) {
382
+ const missing = expertiseOpen.state.reason === 'missing';
383
+ const blocked = expertiseOpen.state.reason?.startsWith('blocked:');
384
+ return {
385
+ stage: 'in_loop',
386
+ stage_num: 5,
387
+ details: {
388
+ version: current,
389
+ counts,
390
+ in_progress_ids: inProgressIds,
391
+ expertise_intent: expertiseOpen.intent.id,
392
+ expertise_reason: expertiseOpen.state.reason,
393
+ },
394
+ auto,
395
+ next_action: missing
396
+ ? '创建搜索计划并执行外部能力获取'
397
+ : blocked
398
+ ? '解决 Expertise Pack 记录的外部获取阻塞'
399
+ : '补齐来源化 Expertise Pack',
400
+ next_command: missing
401
+ ? `loom expertise init ${expertiseOpen.intent.id}`
402
+ : blocked
403
+ ? `loom expertise get ${expertiseOpen.intent.id}`
404
+ : `loom expertise validate ${expertiseOpen.intent.id}`,
405
+ message: blocked
406
+ ? `${expertiseOpen.intent.id} 的外部能力获取已明确 blocked:${expertiseOpen.state.reason.slice('blocked: '.length)}。满足 Pack 中的 recovery_condition 后再继续;不能让模型补齐空白。`
407
+ : `${expertiseOpen.intent.id} 需要外部能力获取。先由任务信号派生搜索词,实际使用 find skill、网络搜索、官方文档或研究资料,再把可回查来源编译为 Capability Capsules;模型临时生成内容不能代替来源。`,
408
+ };
409
+ }
376
410
  const atelierWithoutRecord = allIntents.find((intent) => intent.status === 'in_progress'
377
411
  && intent.quality_strategy === 'atelier'
378
412
  && !existsSync(join(versionDir, '09_ATELIER', `${intent.id}.json`)));
package/cli/src/init.js CHANGED
@@ -51,7 +51,8 @@ export function createVersionStructure(projectDir, version, parentVersion = null
51
51
  `.loom/${v}/07_CAPABILITY_BRIEFS`,
52
52
  `.loom/${v}/07_GRAPH_PROPOSALS`,
53
53
  `.loom/${v}/08_ASSET_LIBRARY/files`,
54
- ];
54
+ `.loom/${v}/10_EXPERTISE_PACKS`,
55
+ ];
55
56
 
56
57
  for (const d of dirs) {
57
58
  const path = join(cwd, d);
@@ -168,6 +169,7 @@ export function initProject(projectDir) {
168
169
  '3. 只在当前角色的权限内行动;发现目标、契约或架构需要改变时,按 LOOM 回流,不要静默扩展范围。',
169
170
  '4. 完成前运行当前 Intent 的验证方法与 `loom doctor`;声称质量提升时必须提供基线相对 Quality Proof,以磁盘证据而非会话记忆判断状态。',
170
171
  '5. Keeper 验证必须运行在新的 Agent thread 中;同一会话切换角色不构成独立验证。',
172
+ '6. 协作节奏默认是手动;需要让 Agent 在已允许阶段连续推进时,显式运行 `loom auto on`。AUTO 不会跳过契约、证据或 Keeper 门禁。',
171
173
  '',
172
174
  '常用入口:',
173
175
  '- `loom --help`',