@haaaiawd/loom 1.2.2 → 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 -58
- package/CONTRIBUTING.md +37 -0
- package/EVIL_EVAL.md +112 -0
- package/README.md +193 -446
- package/README.zh-CN.md +174 -0
- package/SECURITY.md +11 -0
- package/cli/bin/loom.js +170 -995
- 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/capability.md +0 -73
- package/cli/help/concepts.md +0 -103
- package/cli/help/doctor.md +0 -73
- package/cli/help/expertise.md +0 -51
- package/cli/help/loop.md +0 -134
- package/cli/help/patch.md +0 -33
- package/cli/help/preview.md +0 -60
- package/cli/help/proposals.md +0 -21
- package/cli/help/version.md +0 -136
- package/cli/help/workflow.md +0 -114
- package/cli/src/activate.js +0 -473
- package/cli/src/asset-library.js +0 -384
- package/cli/src/atelier.js +0 -331
- package/cli/src/auto.js +0 -116
- package/cli/src/capability-graph.js +0 -430
- package/cli/src/capability-proposals.js +0 -225
- package/cli/src/diagnostics.js +0 -766
- package/cli/src/expertise-pack.js +0 -336
- package/cli/src/guide.js +0 -495
- 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/preview-prompt.md +0 -337
- package/cli/src/preview.js +0 -73
- 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 -86
- package/roles/forge.md +0 -112
- 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/CAPABILITY_BRIEF_TEMPLATE.md +0 -35
- package/templates/CAPABILITY_GRAPH_TEMPLATE.json +0 -11
- 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
|
@@ -1,430 +0,0 @@
|
|
|
1
|
-
// capability-graph.js — 项目初衷到能力获取的可审计展开图。
|
|
2
|
-
|
|
3
|
-
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
-
import { loadIntentMap } from './intent-map.js';
|
|
6
|
-
import { readJsonFile } from './shared/md-utils.js';
|
|
7
|
-
|
|
8
|
-
const GRAPH_FILE = '07_CAPABILITY_GRAPH.json';
|
|
9
|
-
const BRIEFS_DIR = '07_CAPABILITY_BRIEFS';
|
|
10
|
-
const NODE_KINDS = ['outcome', 'concern', 'capability', 'risk', 'evidence'];
|
|
11
|
-
const NODE_STATUSES = ['open', 'researched', 'covered', 'deferred', 'out_of_scope'];
|
|
12
|
-
const ROUTES = ['expand', 'brief', 'intent', 'defer', 'exclude', 'covered_by'];
|
|
13
|
-
const RELATIONSHIPS = ['refines', 'requires', 'realizes', 'constrains', 'risks', 'validated_by', 'covered_by'];
|
|
14
|
-
const ACQUISITION_MODES = ['adaptive', 'external_required', 'project_only'];
|
|
15
|
-
const VERIFICATION_FIELDS = ['method', 'target', 'procedure', 'pass_criteria', 'artifact'];
|
|
16
|
-
const EVIDENCE_ARTIFACT_DIRS = ['verifications', '08_ASSET_LIBRARY/files'];
|
|
17
|
-
|
|
18
|
-
export function getCapabilityGraphPath(versionDir) {
|
|
19
|
-
return join(versionDir, GRAPH_FILE);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function getCapabilityBriefsDir(versionDir) {
|
|
23
|
-
return join(versionDir, BRIEFS_DIR);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function assertString(value, label, errors) {
|
|
27
|
-
if (typeof value !== 'string' || value.trim() === '') errors.push(`${label} 必须是非空字符串`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function validateNode(id, node, allIds, errors) {
|
|
31
|
-
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
32
|
-
errors.push(`nodes["${id}"] 必须是对象`);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (node.id !== id) errors.push(`nodes["${id}"].id 必须与 key 一致`);
|
|
36
|
-
assertString(node.title, `nodes["${id}"].title`, errors);
|
|
37
|
-
if (!NODE_KINDS.includes(node.kind)) errors.push(`nodes["${id}"].kind 非法: ${node.kind}`);
|
|
38
|
-
if (!NODE_STATUSES.includes(node.status)) errors.push(`nodes["${id}"].status 非法: ${node.status}`);
|
|
39
|
-
if (!['low', 'medium', 'high'].includes(node.impact)) errors.push(`nodes["${id}"].impact 非法: ${node.impact}`);
|
|
40
|
-
if (!ROUTES.includes(node.route)) errors.push(`nodes["${id}"].route 非法: ${node.route}`);
|
|
41
|
-
if (node.intent_refs !== undefined && (!Array.isArray(node.intent_refs) || node.intent_refs.some((ref) => typeof ref !== 'string'))) {
|
|
42
|
-
errors.push(`nodes["${id}"].intent_refs 必须是字符串数组`);
|
|
43
|
-
}
|
|
44
|
-
if (node.proposal_refs !== undefined && (!Array.isArray(node.proposal_refs) || node.proposal_refs.some((ref) => typeof ref !== 'string' || !ref.trim()))) {
|
|
45
|
-
errors.push(`nodes["${id}"].proposal_refs 必须是非空字符串数组`);
|
|
46
|
-
}
|
|
47
|
-
if (node.covered_by !== undefined && (typeof node.covered_by !== 'string' || !node.covered_by.trim())) {
|
|
48
|
-
errors.push(`nodes["${id}"].covered_by 必须是非空节点 ID`);
|
|
49
|
-
}
|
|
50
|
-
if (node.relationships !== undefined && !Array.isArray(node.relationships)) {
|
|
51
|
-
errors.push(`nodes["${id}"].relationships 必须是数组`);
|
|
52
|
-
}
|
|
53
|
-
for (const relation of node.relationships || []) {
|
|
54
|
-
if (!relation || typeof relation !== 'object') {
|
|
55
|
-
errors.push(`nodes["${id}"].relationships 含非法关系`);
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (!RELATIONSHIPS.includes(relation.type)) errors.push(`nodes["${id}"].relationships.type 非法: ${relation.type}`);
|
|
59
|
-
if (!allIds.has(relation.target)) errors.push(`nodes["${id}"].relationships 指向不存在节点: ${relation.target}`);
|
|
60
|
-
}
|
|
61
|
-
if (node.brief_ref !== undefined && (typeof node.brief_ref !== 'string' || node.brief_ref.trim() === '')) {
|
|
62
|
-
errors.push(`nodes["${id}"].brief_ref 必须是非空字符串`);
|
|
63
|
-
}
|
|
64
|
-
if (node.asset_refs !== undefined && (!Array.isArray(node.asset_refs) || node.asset_refs.some((ref) => typeof ref !== 'string' || !ref.trim()))) {
|
|
65
|
-
errors.push(`nodes["${id}"].asset_refs 必须是字符串数组`);
|
|
66
|
-
}
|
|
67
|
-
if (node.asset_refs !== undefined && node.kind !== 'evidence') {
|
|
68
|
-
errors.push(`nodes["${id}"].asset_refs 只允许写在 evidence 节点`);
|
|
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
|
-
}
|
|
85
|
-
if (node.verification !== undefined) {
|
|
86
|
-
if (node.kind !== 'evidence') {
|
|
87
|
-
errors.push(`nodes["${id}"].verification 只允许写在 evidence 节点`);
|
|
88
|
-
} else if (!node.verification || typeof node.verification !== 'object' || Array.isArray(node.verification)) {
|
|
89
|
-
errors.push(`nodes["${id}"].verification 必须是对象`);
|
|
90
|
-
} else {
|
|
91
|
-
for (const field of VERIFICATION_FIELDS) {
|
|
92
|
-
assertString(node.verification[field], `nodes["${id}"].verification.${field}`, errors);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function validateCapabilityGraph(data) {
|
|
99
|
-
const errors = [];
|
|
100
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
101
|
-
throw new Error('Capability Graph 必须是 JSON 对象');
|
|
102
|
-
}
|
|
103
|
-
if (!data.nodes || typeof data.nodes !== 'object' || Array.isArray(data.nodes)) {
|
|
104
|
-
errors.push('缺少 nodes 对象');
|
|
105
|
-
} else {
|
|
106
|
-
const ids = new Set(Object.keys(data.nodes));
|
|
107
|
-
for (const [id, node] of Object.entries(data.nodes)) validateNode(id, node, ids, errors);
|
|
108
|
-
for (const [id, node] of Object.entries(data.nodes)) {
|
|
109
|
-
if (node?.route !== 'covered_by') continue;
|
|
110
|
-
const targetId = node.covered_by;
|
|
111
|
-
const target = data.nodes[targetId];
|
|
112
|
-
if (!targetId) errors.push(`nodes["${id}"].route=covered_by 必须声明 covered_by`);
|
|
113
|
-
else if (targetId === id) errors.push(`nodes["${id}"].covered_by 不得指向自身`);
|
|
114
|
-
else if (!target) errors.push(`nodes["${id}"].covered_by 指向不存在节点: ${targetId}`);
|
|
115
|
-
else if (target.status !== 'covered' || target.route === 'covered_by') errors.push(`nodes["${id}"].covered_by 必须指向已覆盖且非 covered_by 的真实节点: ${targetId}`);
|
|
116
|
-
else if (!(node.relationships || []).some((relation) => relation.type === 'covered_by' && relation.target === targetId)) {
|
|
117
|
-
errors.push(`nodes["${id}"].route=covered_by 必须有 covered_by 关系指向 ${targetId}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (data.constraints !== undefined) {
|
|
121
|
-
if (!Array.isArray(data.constraints)) errors.push('constraints 必须是数组');
|
|
122
|
-
else {
|
|
123
|
-
const seen = new Set();
|
|
124
|
-
for (const [index, constraint] of data.constraints.entries()) {
|
|
125
|
-
const prefix = `constraints[${index}]`;
|
|
126
|
-
if (!constraint || typeof constraint !== 'object' || Array.isArray(constraint)) { errors.push(`${prefix} 必须是对象`); continue; }
|
|
127
|
-
assertString(constraint.proposal_id, `${prefix}.proposal_id`, errors);
|
|
128
|
-
assertString(constraint.title, `${prefix}.title`, errors);
|
|
129
|
-
if (!Array.isArray(constraint.node_refs) || constraint.node_refs.length === 0) errors.push(`${prefix}.node_refs 必须包含至少一个 Graph 节点`);
|
|
130
|
-
else for (const nodeId of constraint.node_refs) if (!ids.has(nodeId)) errors.push(`${prefix}.node_refs 指向不存在节点: ${nodeId}`);
|
|
131
|
-
if (seen.has(constraint.proposal_id)) errors.push(`${prefix}.proposal_id 重复: ${constraint.proposal_id}`);
|
|
132
|
-
seen.add(constraint.proposal_id);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (errors.length) throw new Error(`Capability Graph 校验失败:\n - ${errors.join('\n - ')}`);
|
|
138
|
-
return data;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function loadCapabilityGraph(versionDir, { required = true } = {}) {
|
|
142
|
-
const filePath = getCapabilityGraphPath(versionDir);
|
|
143
|
-
if (!existsSync(filePath)) {
|
|
144
|
-
if (required) throw new Error(`缺少 ${GRAPH_FILE}`);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
return validateCapabilityGraph(readJsonFile(filePath, 'Capability Graph'));
|
|
148
|
-
}
|
|
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
|
-
|
|
157
|
-
function isRouted(node) {
|
|
158
|
-
return ['brief', 'intent', 'defer', 'exclude', 'covered_by'].includes(node.route);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function incoming(nodes, nodeId) {
|
|
162
|
-
const result = [];
|
|
163
|
-
for (const [id, node] of Object.entries(nodes)) {
|
|
164
|
-
for (const relation of node.relationships || []) {
|
|
165
|
-
if (relation.target === nodeId) result.push({ source: id, type: relation.type });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return result;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function getCapabilityNode(versionDir, nodeId) {
|
|
172
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
173
|
-
const node = graph.nodes[nodeId];
|
|
174
|
-
if (!node) throw new Error(`Capability 节点不存在: ${nodeId}`);
|
|
175
|
-
return { node, incoming: incoming(graph.nodes, nodeId), outgoing: node.relationships || [] };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function getCapabilityFrontier(versionDir) {
|
|
179
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
180
|
-
return Object.values(graph.nodes)
|
|
181
|
-
.filter((node) => node.impact === 'high' && (!isRouted(node) || node.status === 'open'))
|
|
182
|
-
.map((node) => ({ id: node.id, title: node.title, kind: node.kind, status: node.status, route: node.route, question: node.question || null }));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function getCapabilityGraphProjection(versionDir) {
|
|
186
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
187
|
-
const lines = ['```mermaid', 'flowchart LR'];
|
|
188
|
-
for (const node of Object.values(graph.nodes)) {
|
|
189
|
-
const label = `${node.id}<br/>${String(node.title).replace(/"/g, '"')}`;
|
|
190
|
-
const shape = node.kind === 'outcome' ? `(["${label}"])`
|
|
191
|
-
: node.kind === 'risk' ? `{{"${label}"}}`
|
|
192
|
-
: node.kind === 'capability' ? `[["${label}"]]`
|
|
193
|
-
: `("${label}")`;
|
|
194
|
-
lines.push(` ${node.id.replace(/[^A-Za-z0-9_]/g, '_')}${shape}`);
|
|
195
|
-
}
|
|
196
|
-
for (const node of Object.values(graph.nodes)) {
|
|
197
|
-
for (const relation of node.relationships || []) {
|
|
198
|
-
lines.push(` ${node.id.replace(/[^A-Za-z0-9_]/g, '_')} -->|${relation.type}| ${relation.target.replace(/[^A-Za-z0-9_]/g, '_')}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
lines.push('```');
|
|
202
|
-
return {
|
|
203
|
-
summary: {
|
|
204
|
-
total: Object.keys(graph.nodes).length,
|
|
205
|
-
by_kind: Object.fromEntries(NODE_KINDS.map((kind) => [kind, Object.values(graph.nodes).filter((node) => node.kind === kind).length])),
|
|
206
|
-
frontier: getCapabilityFrontier(versionDir).length,
|
|
207
|
-
},
|
|
208
|
-
mermaid: lines.join('\n'),
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function resolveBrief(versionDir, briefRef) {
|
|
213
|
-
if (!briefRef) return null;
|
|
214
|
-
if (isAbsolute(briefRef)) throw new Error(`Capability Brief 不得使用绝对路径: ${briefRef}`);
|
|
215
|
-
const versionRoot = resolve(versionDir);
|
|
216
|
-
const filePath = resolve(versionDir, briefRef);
|
|
217
|
-
const relation = relative(versionRoot, filePath);
|
|
218
|
-
if (relation.startsWith('..') || isAbsolute(relation)) throw new Error(`Capability Brief 不得越出当前版本目录: ${briefRef}`);
|
|
219
|
-
const briefsRoot = resolve(getCapabilityBriefsDir(versionDir));
|
|
220
|
-
const briefRelation = relative(briefsRoot, filePath);
|
|
221
|
-
if (briefRelation.startsWith('..') || isAbsolute(briefRelation)) throw new Error(`Capability Brief 必须位于 ${BRIEFS_DIR}/: ${briefRef}`);
|
|
222
|
-
if (!existsSync(filePath)) throw new Error(`Capability Brief 不存在: ${briefRef}`);
|
|
223
|
-
return { ref: briefRef, content: readFileSync(filePath, 'utf-8') };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function resolveEvidenceArtifact(versionDir, artifactRef) {
|
|
227
|
-
if (typeof artifactRef !== 'string' || !artifactRef.trim() || isAbsolute(artifactRef)) return null;
|
|
228
|
-
const versionRoot = resolve(versionDir);
|
|
229
|
-
const artifactPath = resolve(versionDir, artifactRef);
|
|
230
|
-
const withinVersion = relative(versionRoot, artifactPath);
|
|
231
|
-
if (withinVersion.startsWith('..') || isAbsolute(withinVersion)) return null;
|
|
232
|
-
const allowed = EVIDENCE_ARTIFACT_DIRS.some((directory) => {
|
|
233
|
-
const allowedRoot = resolve(versionDir, directory);
|
|
234
|
-
const relation = relative(allowedRoot, artifactPath);
|
|
235
|
-
return !relation.startsWith('..') && !isAbsolute(relation) && relation !== '';
|
|
236
|
-
});
|
|
237
|
-
if (!allowed || !existsSync(artifactPath) || !lstatSync(artifactPath).isFile()) return null;
|
|
238
|
-
return artifactPath;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function getHighOutcomesWithoutObservableEvidence(versionDir, graph, intentMap, intentMappingRequired) {
|
|
242
|
-
const gaps = [];
|
|
243
|
-
for (const outcome of Object.values(graph.nodes)) {
|
|
244
|
-
if (outcome.kind !== 'outcome' || outcome.impact !== 'high') continue;
|
|
245
|
-
const evidenceRelations = (outcome.relationships || []).filter((relation) => relation.type === 'validated_by');
|
|
246
|
-
const validEvidence = evidenceRelations.find((relation) => {
|
|
247
|
-
const evidence = graph.nodes[relation.target];
|
|
248
|
-
if (!evidence || evidence.kind !== 'evidence' || !evidence.verification) return false;
|
|
249
|
-
if (!resolveEvidenceArtifact(versionDir, evidence.verification.artifact)) return false;
|
|
250
|
-
const hasOwner = (evidence.intent_refs || []).some((intentId) => !intentMappingRequired || intentId in intentMap.intents);
|
|
251
|
-
return hasOwner;
|
|
252
|
-
});
|
|
253
|
-
if (!validEvidence) {
|
|
254
|
-
gaps.push({
|
|
255
|
-
node_id: outcome.id,
|
|
256
|
-
reason: '高影响 outcome 必须以 validated_by 连接到带 method、target、procedure、pass_criteria、artifact 和 Intent 回链的 evidence 节点',
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return gaps;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function getCapabilityCoverage(versionDir) {
|
|
264
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
265
|
-
const intentMap = loadIntentMap(versionDir);
|
|
266
|
-
const intentMappingRequired = intentMap._meta?._template !== true;
|
|
267
|
-
const nodes = Object.values(graph.nodes);
|
|
268
|
-
const highUnrouted = getCapabilityFrontier(versionDir);
|
|
269
|
-
const orphanIntentRefs = [];
|
|
270
|
-
const mappedIntentIds = new Set();
|
|
271
|
-
const capabilitiesWithoutPlan = [];
|
|
272
|
-
const routingGaps = [];
|
|
273
|
-
const outcomesWithoutConcern = [];
|
|
274
|
-
const evidenceArtifactGaps = [];
|
|
275
|
-
|
|
276
|
-
for (const node of nodes) {
|
|
277
|
-
for (const intentId of node.intent_refs || []) {
|
|
278
|
-
if (!intentMappingRequired) continue;
|
|
279
|
-
if (!(intentId in intentMap.intents)) orphanIntentRefs.push({ node_id: node.id, intent_id: intentId });
|
|
280
|
-
else mappedIntentIds.add(intentId);
|
|
281
|
-
}
|
|
282
|
-
if (node.route === 'intent' && (!node.intent_refs || node.intent_refs.length === 0)) {
|
|
283
|
-
routingGaps.push({ node_id: node.id, reason: 'route=intent 但缺少 intent_refs' });
|
|
284
|
-
}
|
|
285
|
-
if (node.route === 'brief' && !node.brief_ref) {
|
|
286
|
-
capabilitiesWithoutPlan.push({ node_id: node.id, reason: 'route=brief 但缺少 brief_ref' });
|
|
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
|
-
}
|
|
301
|
-
if (['defer', 'exclude'].includes(node.route) && (!node.rationale || typeof node.rationale !== 'string' || node.rationale.trim() === '')) {
|
|
302
|
-
routingGaps.push({ node_id: node.id, reason: `route=${node.route} 但缺少 rationale` });
|
|
303
|
-
}
|
|
304
|
-
if (node.route === 'defer' && (!node.revisit_when || typeof node.revisit_when !== 'string' || node.revisit_when.trim() === '')) {
|
|
305
|
-
routingGaps.push({ node_id: node.id, reason: 'route=defer 但缺少 revisit_when' });
|
|
306
|
-
}
|
|
307
|
-
if (node.route === 'covered_by' && (node.relationships || []).length === 0) {
|
|
308
|
-
routingGaps.push({ node_id: node.id, reason: 'route=covered_by 但没有指向覆盖节点的关系' });
|
|
309
|
-
}
|
|
310
|
-
if (node.kind === 'outcome' && !(node.relationships || []).some((relation) => graph.nodes[relation.target]?.kind === 'concern')) {
|
|
311
|
-
outcomesWithoutConcern.push({ node_id: node.id, reason: 'outcome 没有连接到 concern,项目初衷尚未展开为问题面' });
|
|
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
|
-
}
|
|
319
|
-
if (node.brief_ref) {
|
|
320
|
-
try { resolveBrief(versionDir, node.brief_ref); } catch (error) {
|
|
321
|
-
capabilitiesWithoutPlan.push({ node_id: node.id, reason: error.message });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const unmappedIntents = intentMappingRequired
|
|
327
|
-
? Object.keys(intentMap.intents).filter((id) => !mappedIntentIds.has(id))
|
|
328
|
-
: [];
|
|
329
|
-
const highOutcomesWithoutObservableEvidence = getHighOutcomesWithoutObservableEvidence(versionDir, graph, intentMap, intentMappingRequired);
|
|
330
|
-
return {
|
|
331
|
-
summary: {
|
|
332
|
-
nodes: nodes.length,
|
|
333
|
-
outcomes: nodes.filter((node) => node.kind === 'outcome').length,
|
|
334
|
-
high_unrouted: highUnrouted.length,
|
|
335
|
-
orphan_intent_refs: orphanIntentRefs.length,
|
|
336
|
-
capabilities_without_plan: capabilitiesWithoutPlan.length,
|
|
337
|
-
routing_gaps: routingGaps.length,
|
|
338
|
-
outcomes_without_concern: outcomesWithoutConcern.length,
|
|
339
|
-
high_outcomes_without_observable_evidence: highOutcomesWithoutObservableEvidence.length,
|
|
340
|
-
evidence_artifact_gaps: evidenceArtifactGaps.length,
|
|
341
|
-
intent_mapping_required: intentMappingRequired,
|
|
342
|
-
unmapped_intents: unmappedIntents.length,
|
|
343
|
-
ready: highUnrouted.length === 0
|
|
344
|
-
&& orphanIntentRefs.length === 0
|
|
345
|
-
&& capabilitiesWithoutPlan.length === 0
|
|
346
|
-
&& routingGaps.length === 0
|
|
347
|
-
&& outcomesWithoutConcern.length === 0
|
|
348
|
-
&& highOutcomesWithoutObservableEvidence.length === 0
|
|
349
|
-
&& evidenceArtifactGaps.length === 0
|
|
350
|
-
&& unmappedIntents.length === 0,
|
|
351
|
-
},
|
|
352
|
-
high_unrouted: highUnrouted,
|
|
353
|
-
orphan_intent_refs: orphanIntentRefs,
|
|
354
|
-
capabilities_without_plan: capabilitiesWithoutPlan,
|
|
355
|
-
routing_gaps: routingGaps,
|
|
356
|
-
outcomes_without_concern: outcomesWithoutConcern,
|
|
357
|
-
high_outcomes_without_observable_evidence: highOutcomesWithoutObservableEvidence,
|
|
358
|
-
evidence_artifact_gaps: evidenceArtifactGaps,
|
|
359
|
-
unmapped_intents: unmappedIntents,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function collectCompilationNodes(graph, intentId) {
|
|
364
|
-
const selected = new Set(Object.values(graph.nodes)
|
|
365
|
-
.filter((node) => (node.intent_refs || []).includes(intentId))
|
|
366
|
-
.map((node) => node.id));
|
|
367
|
-
const queue = [...selected];
|
|
368
|
-
while (queue.length) {
|
|
369
|
-
const current = queue.shift();
|
|
370
|
-
for (const { source, type } of incoming(graph.nodes, current)) {
|
|
371
|
-
if (type === 'refines' && !selected.has(source)) {
|
|
372
|
-
selected.add(source);
|
|
373
|
-
queue.push(source);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
for (const relation of graph.nodes[current].relationships || []) {
|
|
377
|
-
if (['requires', 'constrains', 'risks'].includes(relation.type) && !selected.has(relation.target)) {
|
|
378
|
-
selected.add(relation.target);
|
|
379
|
-
queue.push(relation.target);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return [...selected].map((id) => graph.nodes[id]);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export function compileCapabilityInputs(versionDir, intentId) {
|
|
387
|
-
const graph = loadCapabilityGraph(versionDir, { required: false });
|
|
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
|
-
}
|
|
397
|
-
const nodes = collectCompilationNodes(graph, intentId);
|
|
398
|
-
const briefs = [];
|
|
399
|
-
const warnings = [];
|
|
400
|
-
for (const node of nodes) {
|
|
401
|
-
if (!node.brief_ref) continue;
|
|
402
|
-
try { briefs.push({ node_id: node.id, ...resolveBrief(versionDir, node.brief_ref) }); } catch (error) { warnings.push(`${node.id}: ${error.message}`); }
|
|
403
|
-
}
|
|
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
|
-
};
|
|
430
|
-
}
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
// capability-proposals — auditable incoming change, deliberately separate from the official graph.
|
|
2
|
-
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
-
import { loadCapabilityGraph } from './capability-graph.js';
|
|
7
|
-
import { loadIntentMap } from './intent-map.js';
|
|
8
|
-
import { readJsonFile } from './shared/md-utils.js';
|
|
9
|
-
|
|
10
|
-
const DIR = '07_GRAPH_PROPOSALS';
|
|
11
|
-
const ORIGINS = ['user_request', 'research', 'keeper_finding', 'forge_finding'];
|
|
12
|
-
const CANDIDATES = ['outcome', 'concern', 'capability', 'risk', 'evidence', 'constraint'];
|
|
13
|
-
const DECISIONS = ['covered', 'graph_update', 'intent_change', 'acceptance_change', 'minor', 'major', 'reject'];
|
|
14
|
-
const DECISION_ARTIFACT_DIR = '03_DECISIONS';
|
|
15
|
-
|
|
16
|
-
export function getCapabilityProposalsDir(versionDir) { return join(versionDir, DIR); }
|
|
17
|
-
|
|
18
|
-
function proposalPath(versionDir, id) {
|
|
19
|
-
if (!/^CGP-[A-Z0-9][A-Z0-9-]*$/.test(id || '')) throw new Error('proposal id must use CGP-<UPPERCASE-ID>');
|
|
20
|
-
return join(getCapabilityProposalsDir(versionDir), `${id}.json`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function text(value, name, errors) { if (typeof value !== 'string' || !value.trim()) errors.push(`${name} must be a non-empty string`); }
|
|
24
|
-
|
|
25
|
-
function fileHash(path) {
|
|
26
|
-
return existsSync(path) && statSync(path).isFile()
|
|
27
|
-
? createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
28
|
-
: null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function directoryHash(path) {
|
|
32
|
-
if (!existsSync(path)) return null;
|
|
33
|
-
const entries = readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
34
|
-
const digest = createHash('sha256');
|
|
35
|
-
for (const entry of entries) {
|
|
36
|
-
const child = join(path, entry.name);
|
|
37
|
-
digest.update(entry.name);
|
|
38
|
-
digest.update(entry.isDirectory() ? directoryHash(child) || '' : fileHash(child) || '');
|
|
39
|
-
}
|
|
40
|
-
return digest.digest('hex');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function acceptanceHash(versionDir) {
|
|
44
|
-
const mapPath = join(versionDir, '04_INTENT_MAP.json');
|
|
45
|
-
const verificationPath = join(versionDir, '05_VERIFICATION.md');
|
|
46
|
-
let inline = null;
|
|
47
|
-
if (existsSync(mapPath)) {
|
|
48
|
-
try {
|
|
49
|
-
const map = JSON.parse(readFileSync(mapPath, 'utf-8'));
|
|
50
|
-
inline = Object.fromEntries(Object.entries(map.intents || {}).map(([id, intent]) => [id, intent.acceptance]));
|
|
51
|
-
} catch { inline = null; }
|
|
52
|
-
}
|
|
53
|
-
return createHash('sha256').update(JSON.stringify(inline)).update(fileHash(verificationPath) || '').digest('hex');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function snapshot(versionDir) {
|
|
57
|
-
return {
|
|
58
|
-
graph: fileHash(join(versionDir, '07_CAPABILITY_GRAPH.json')),
|
|
59
|
-
intent_map: fileHash(join(versionDir, '04_INTENT_MAP.json')),
|
|
60
|
-
acceptance: acceptanceHash(versionDir),
|
|
61
|
-
decisions: directoryHash(join(versionDir, DECISION_ARTIFACT_DIR)),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function nonEmptyStringArray(value, label, errors) {
|
|
66
|
-
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !item.trim())) errors.push(`${label} must be a non-empty string array`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function validateResolutionShape(resolution, decision, errors) {
|
|
70
|
-
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) {
|
|
71
|
-
errors.push('resolution must be an object');
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
if (decision === 'graph_update') {
|
|
75
|
-
if (!resolution.graph || typeof resolution.graph !== 'object' || Array.isArray(resolution.graph)) errors.push('resolution.graph is required for graph_update');
|
|
76
|
-
else nonEmptyStringArray(resolution.graph.node_ids, 'resolution.graph.node_ids', errors);
|
|
77
|
-
}
|
|
78
|
-
if (decision === 'intent_change') nonEmptyStringArray(resolution.intent_ids, 'resolution.intent_ids', errors);
|
|
79
|
-
if (decision === 'acceptance_change') nonEmptyStringArray(resolution.acceptance_intent_ids, 'resolution.acceptance_intent_ids', errors);
|
|
80
|
-
if (decision === 'covered') {
|
|
81
|
-
if (!resolution.coverage || typeof resolution.coverage !== 'object' || Array.isArray(resolution.coverage)) errors.push('resolution.coverage is required for covered');
|
|
82
|
-
else {
|
|
83
|
-
nonEmptyStringArray(resolution.coverage.node_ids, 'resolution.coverage.node_ids', errors);
|
|
84
|
-
text(resolution.coverage.rationale, 'resolution.coverage.rationale', errors);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (['minor', 'major', 'reject'].includes(decision)) text(resolution.decision_artifact_ref, 'resolution.decision_artifact_ref', errors);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function validateCapabilityProposal(proposal) {
|
|
91
|
-
const errors = [];
|
|
92
|
-
text(proposal?.id, 'id', errors);
|
|
93
|
-
if (!ORIGINS.includes(proposal?.origin)) errors.push(`origin must be one of ${ORIGINS.join(', ')}`);
|
|
94
|
-
if (!CANDIDATES.includes(proposal?.candidate_kind)) errors.push(`candidate_kind must be one of ${CANDIDATES.join(', ')}`);
|
|
95
|
-
text(proposal?.title, 'title', errors);
|
|
96
|
-
text(proposal?.why_now, 'why_now', errors);
|
|
97
|
-
if (!proposal?.provenance || typeof proposal.provenance !== 'object') errors.push('provenance is required');
|
|
98
|
-
else {
|
|
99
|
-
text(proposal.provenance.source, 'provenance.source', errors);
|
|
100
|
-
text(proposal.provenance.observed_at, 'provenance.observed_at', errors);
|
|
101
|
-
text(proposal.provenance.evidence, 'provenance.evidence', errors);
|
|
102
|
-
}
|
|
103
|
-
if (!['submitted', 'decided', 'closed'].includes(proposal?.status)) errors.push('status must be submitted, decided, or closed');
|
|
104
|
-
if (proposal?.status !== 'submitted') {
|
|
105
|
-
if (!DECISIONS.includes(proposal?.decision)) errors.push(`decision must be one of ${DECISIONS.join(', ')}`);
|
|
106
|
-
text(proposal?.decision_rationale, 'decision_rationale', errors);
|
|
107
|
-
if (!proposal.decision_baseline || typeof proposal.decision_baseline !== 'object') errors.push('decision_baseline is required after Architect decision');
|
|
108
|
-
}
|
|
109
|
-
if (proposal?.status === 'closed') validateResolutionShape(proposal.resolution, proposal.decision, errors);
|
|
110
|
-
if (errors.length) throw new Error(`Capability Graph proposal validation failed:\n - ${errors.join('\n - ')}`);
|
|
111
|
-
return proposal;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function listCapabilityProposals(versionDir, { unresolvedOnly = false } = {}) {
|
|
115
|
-
const dir = getCapabilityProposalsDir(versionDir);
|
|
116
|
-
if (!existsSync(dir)) return [];
|
|
117
|
-
const proposals = readdirSync(dir).filter((file) => file.endsWith('.json')).sort()
|
|
118
|
-
.map((file) => validateCapabilityProposal(readJsonFile(join(dir, file), 'Capability Graph proposal')));
|
|
119
|
-
return unresolvedOnly ? proposals.filter((proposal) => proposal.status !== 'closed') : proposals;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function getCapabilityProposal(versionDir, id) {
|
|
123
|
-
const path = proposalPath(versionDir, id);
|
|
124
|
-
if (!existsSync(path)) throw new Error(`proposal not found: ${id}`);
|
|
125
|
-
return validateCapabilityProposal(readJsonFile(path, 'Capability Graph proposal'));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function submitCapabilityProposal(versionDir, proposal) {
|
|
129
|
-
validateCapabilityProposal(proposal);
|
|
130
|
-
if (proposal.status !== 'submitted') throw new Error('new proposal must start as status=submitted');
|
|
131
|
-
const path = proposalPath(versionDir, proposal.id);
|
|
132
|
-
if (existsSync(path)) throw new Error(`proposal already exists: ${proposal.id}`);
|
|
133
|
-
mkdirSync(getCapabilityProposalsDir(versionDir), { recursive: true });
|
|
134
|
-
writeFileSync(path, `${JSON.stringify(proposal, null, 2)}\n`, 'utf-8');
|
|
135
|
-
return proposal;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function decideCapabilityProposal(versionDir, id, decision, rationale) {
|
|
139
|
-
if (!DECISIONS.includes(decision)) throw new Error(`invalid decision: ${decision}`);
|
|
140
|
-
if (!rationale || !rationale.trim()) throw new Error('--rationale is required');
|
|
141
|
-
const proposal = getCapabilityProposal(versionDir, id);
|
|
142
|
-
if (proposal.status !== 'submitted') throw new Error(`${id} is already ${proposal.status}; it cannot be decided again`);
|
|
143
|
-
proposal.status = 'decided';
|
|
144
|
-
proposal.decision = decision;
|
|
145
|
-
proposal.decision_rationale = rationale;
|
|
146
|
-
proposal.decision_baseline = snapshot(versionDir);
|
|
147
|
-
writeFileSync(proposalPath(versionDir, id), `${JSON.stringify(proposal, null, 2)}\n`, 'utf-8');
|
|
148
|
-
return proposal;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function isEffectiveCoverage(node) {
|
|
152
|
-
return node?.status === 'covered' && ['brief', 'intent', 'defer', 'exclude', 'covered_by'].includes(node.route);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function assertGraphResolution(versionDir, proposal, resolution) {
|
|
156
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
157
|
-
if (snapshot(versionDir).graph === proposal.decision_baseline.graph) throw new Error('graph_update requires a real change to 07_CAPABILITY_GRAPH.json after the Architect decision');
|
|
158
|
-
for (const nodeId of resolution.graph.node_ids) {
|
|
159
|
-
const node = graph.nodes[nodeId];
|
|
160
|
-
if (!node) throw new Error(`resolution.graph.node_ids references missing Graph node: ${nodeId}`);
|
|
161
|
-
if (!(node.proposal_refs || []).includes(proposal.id)) throw new Error(`Graph node ${nodeId} must record proposal_refs including ${proposal.id}`);
|
|
162
|
-
}
|
|
163
|
-
if (proposal.candidate_kind === 'constraint') {
|
|
164
|
-
const carrier = (graph.constraints || []).find((constraint) => constraint.proposal_id === proposal.id);
|
|
165
|
-
if (!carrier) throw new Error(`constraint proposal ${proposal.id} requires a formal Graph constraints carrier`);
|
|
166
|
-
if (!carrier.node_refs.some((nodeId) => resolution.graph.node_ids.includes(nodeId))) throw new Error(`constraint carrier ${proposal.id} must reference a resolved Graph node`);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function assertIntentResolution(versionDir, proposal, resolution, { acceptance = false } = {}) {
|
|
171
|
-
const current = snapshot(versionDir);
|
|
172
|
-
const baselineKey = acceptance ? 'acceptance' : 'intent_map';
|
|
173
|
-
if (current[baselineKey] === proposal.decision_baseline[baselineKey]) {
|
|
174
|
-
throw new Error(`${proposal.decision} requires a real ${acceptance ? 'acceptance contract' : '04_INTENT_MAP.json'} change after the Architect decision`);
|
|
175
|
-
}
|
|
176
|
-
const map = loadIntentMap(versionDir);
|
|
177
|
-
const intentIds = acceptance ? resolution.acceptance_intent_ids : resolution.intent_ids;
|
|
178
|
-
for (const intentId of intentIds) {
|
|
179
|
-
const intent = map.intents[intentId];
|
|
180
|
-
if (!intent) throw new Error(`resolution references missing Intent: ${intentId}`);
|
|
181
|
-
if (!(intent.proposal_refs || []).includes(proposal.id)) throw new Error(`Intent ${intentId} must record proposal_refs including ${proposal.id}`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function resolveDecisionArtifact(versionDir, reference) {
|
|
186
|
-
if (typeof reference !== 'string' || !reference.trim() || isAbsolute(reference)) throw new Error('resolution.decision_artifact_ref must be a relative decision artifact path');
|
|
187
|
-
const decisionsRoot = resolve(versionDir, DECISION_ARTIFACT_DIR);
|
|
188
|
-
const artifactPath = resolve(versionDir, reference);
|
|
189
|
-
const relation = relative(decisionsRoot, artifactPath);
|
|
190
|
-
if (relation.startsWith('..') || isAbsolute(relation) || relation === '') throw new Error(`decision artifact must be inside ${DECISION_ARTIFACT_DIR}/`);
|
|
191
|
-
if (!existsSync(artifactPath) || !statSync(artifactPath).isFile()) throw new Error(`decision artifact does not exist: ${reference}`);
|
|
192
|
-
return artifactPath;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function assertDecisionArtifactResolution(versionDir, proposal, resolution) {
|
|
196
|
-
const artifactPath = resolveDecisionArtifact(versionDir, resolution.decision_artifact_ref);
|
|
197
|
-
if (snapshot(versionDir).decisions === proposal.decision_baseline.decisions) throw new Error('decision resolution requires a new or changed 03_DECISIONS artifact after the Architect decision');
|
|
198
|
-
if (!readFileSync(artifactPath, 'utf-8').includes(proposal.id)) throw new Error(`decision artifact must name proposal ${proposal.id}`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function assertCoveredResolution(versionDir, resolution) {
|
|
202
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
203
|
-
for (const nodeId of resolution.coverage.node_ids) {
|
|
204
|
-
if (!isEffectiveCoverage(graph.nodes[nodeId])) throw new Error(`coverage node is not an effective current Graph coverage: ${nodeId}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export function closeCapabilityProposal(versionDir, id, resolution) {
|
|
209
|
-
const proposal = getCapabilityProposal(versionDir, id);
|
|
210
|
-
if (proposal.status !== 'decided') throw new Error(`${id} must be decided before it can be closed`);
|
|
211
|
-
const errors = [];
|
|
212
|
-
validateResolutionShape(resolution, proposal.decision, errors);
|
|
213
|
-
if (errors.length) throw new Error(`proposal close requires structured resolution:\n - ${errors.join('\n - ')}`);
|
|
214
|
-
|
|
215
|
-
if (proposal.decision === 'graph_update') assertGraphResolution(versionDir, proposal, resolution);
|
|
216
|
-
else if (proposal.decision === 'intent_change') assertIntentResolution(versionDir, proposal, resolution);
|
|
217
|
-
else if (proposal.decision === 'acceptance_change') assertIntentResolution(versionDir, proposal, resolution, { acceptance: true });
|
|
218
|
-
else if (proposal.decision === 'covered') assertCoveredResolution(versionDir, resolution);
|
|
219
|
-
else assertDecisionArtifactResolution(versionDir, proposal, resolution);
|
|
220
|
-
|
|
221
|
-
proposal.status = 'closed';
|
|
222
|
-
proposal.resolution = resolution;
|
|
223
|
-
writeFileSync(proposalPath(versionDir, id), `${JSON.stringify(proposal, null, 2)}\n`, 'utf-8');
|
|
224
|
-
return proposal;
|
|
225
|
-
}
|