@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.
@@ -0,0 +1,331 @@
1
+ // atelier.js — Atelier Record 的创建、读取与结构校验。
2
+ // 只管理可审计创作记录;不替 Author 生成方案,也不替 Keeper 判断质量。
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { getIntent } from './intent-map.js';
7
+ import { getLoomRoot } from './shared/paths.js';
8
+ import { readJsonFile } from './shared/md-utils.js';
9
+
10
+ const VALID_STATUS = ['draft', 'exploring', 'compared', 'selected', 'baseline_retained', 'blocked'];
11
+ const CORRECTION_CLASSIFICATIONS = ['local_stance', 'graph_candidate', 'reflow', 'learning_candidate'];
12
+
13
+ function recordPath(versionDir, intentId) {
14
+ return join(versionDir, '09_ATELIER', `${intentId}.json`);
15
+ }
16
+
17
+ function pushText(value, field, errors) {
18
+ if (typeof value !== 'string' || value.trim() === '') errors.push(`${field} 必须是非空字符串`);
19
+ }
20
+
21
+ function pushStringArray(value, field, errors, { nonEmpty = false } = {}) {
22
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
23
+ errors.push(`${field} 必须是非空字符串数组`);
24
+ } else if (nonEmpty && value.length === 0) {
25
+ errors.push(`${field} 不能为空`);
26
+ }
27
+ }
28
+
29
+ function artifactRef(versionDir, intentId, value, field, errors) {
30
+ if (typeof value !== 'string' || !value.trim()) {
31
+ errors.push(`${field} 必须是非空 artifact 引用`);
32
+ return;
33
+ }
34
+ const normalized = value.replaceAll('\\', '/');
35
+ const expectedPrefix = `09_ATELIER/files/${intentId}/`;
36
+ const absolute = resolve(versionDir, normalized);
37
+ const rel = relative(versionDir, absolute);
38
+ if (!normalized.startsWith(expectedPrefix) || rel.startsWith('..') || isAbsolute(rel)) {
39
+ errors.push(`${field} 必须位于当前 Intent 的 ${expectedPrefix}`);
40
+ return;
41
+ }
42
+ if (!existsSync(absolute)) errors.push(`${field} 引用不存在: ${normalized}`);
43
+ }
44
+
45
+ function validateStance(record, errors) {
46
+ if (!record.stance || typeof record.stance !== 'object' || Array.isArray(record.stance)) {
47
+ errors.push('stance 必须是对象');
48
+ return;
49
+ }
50
+ if (record.status === 'draft' || record.status === 'blocked') return;
51
+ const stance = record.stance;
52
+ pushText(stance.creative_thesis, 'stance.creative_thesis', errors);
53
+ pushStringArray(stance.gaze, 'stance.gaze', errors, { nonEmpty: true });
54
+ pushText(stance.tension, 'stance.tension', errors);
55
+ if (!stance.signature_bet || typeof stance.signature_bet !== 'object' || Array.isArray(stance.signature_bet)) {
56
+ errors.push('stance.signature_bet 必须是对象');
57
+ } else {
58
+ pushText(stance.signature_bet.claim, 'stance.signature_bet.claim', errors);
59
+ pushText(stance.signature_bet.mechanism, 'stance.signature_bet.mechanism', errors);
60
+ pushText(stance.signature_bet.cost, 'stance.signature_bet.cost', errors);
61
+ }
62
+ pushStringArray(stance.refusals, 'stance.refusals', errors, { nonEmpty: true });
63
+ if (!stance.medium_grammar || typeof stance.medium_grammar !== 'object' || Array.isArray(stance.medium_grammar) || Object.keys(stance.medium_grammar).length === 0) {
64
+ errors.push('stance.medium_grammar 必须是非空对象');
65
+ }
66
+ if (!stance.surprise_budget || typeof stance.surprise_budget !== 'object' || Array.isArray(stance.surprise_budget)) {
67
+ errors.push('stance.surprise_budget 必须是对象');
68
+ } else {
69
+ if (!['low', 'medium', 'high'].includes(stance.surprise_budget.level)) {
70
+ errors.push('stance.surprise_budget.level 必须是 low|medium|high');
71
+ }
72
+ pushText(stance.surprise_budget.allowed, 'stance.surprise_budget.allowed', errors);
73
+ pushText(stance.surprise_budget.protected, 'stance.surprise_budget.protected', errors);
74
+ }
75
+ pushStringArray(stance.anti_fixation, 'stance.anti_fixation', errors, { nonEmpty: true });
76
+ pushStringArray(stance.verification_lens, 'stance.verification_lens', errors, { nonEmpty: true });
77
+ }
78
+
79
+ function validateCorrections(versionDir, intentId, record, errors) {
80
+ if (!Array.isArray(record.corrections)) {
81
+ errors.push('corrections 必须是数组');
82
+ return;
83
+ }
84
+ const local = [];
85
+ for (const [index, correction] of record.corrections.entries()) {
86
+ const prefix = `corrections[${index}]`;
87
+ if (!correction || typeof correction !== 'object' || Array.isArray(correction)) {
88
+ errors.push(`${prefix} 必须是对象`);
89
+ continue;
90
+ }
91
+ if (!Number.isInteger(correction.round) || correction.round < 1) errors.push(`${prefix}.round 必须是正整数`);
92
+ pushText(correction.trigger, `${prefix}.trigger`, errors);
93
+ artifactRef(versionDir, intentId, correction.evidence_ref, `${prefix}.evidence_ref`, errors);
94
+ if (!CORRECTION_CLASSIFICATIONS.includes(correction.classification)) {
95
+ errors.push(`${prefix}.classification 必须是 ${CORRECTION_CLASSIFICATIONS.join('|')}`);
96
+ }
97
+ pushText(correction.change, `${prefix}.change`, errors);
98
+ if (correction.classification === 'local_stance') {
99
+ if (!Number.isInteger(correction.from_stance_revision) || correction.from_stance_revision < 1) {
100
+ errors.push(`${prefix}.from_stance_revision 必须是正整数`);
101
+ }
102
+ if (correction.to_stance_revision !== correction.from_stance_revision + 1) {
103
+ errors.push(`${prefix}.to_stance_revision 必须恰好递增 1`);
104
+ }
105
+ local.push(correction);
106
+ }
107
+ if (correction.classification === 'graph_candidate') {
108
+ if (typeof correction.proposal_ref !== 'string' || !/^CGP-[A-Z0-9-]+$/.test(correction.proposal_ref)) {
109
+ errors.push(`${prefix}.proposal_ref 必须是 CGP-* ID`);
110
+ } else if (!existsSync(join(versionDir, '07_GRAPH_PROPOSALS', `${correction.proposal_ref}.json`))) {
111
+ errors.push(`${prefix}.proposal_ref 引用不存在: ${correction.proposal_ref}`);
112
+ }
113
+ }
114
+ }
115
+ const ordered = [...local].sort((a, b) => a.to_stance_revision - b.to_stance_revision);
116
+ for (let index = 0; index < ordered.length; index += 1) {
117
+ const expectedFrom = index + 1;
118
+ if (ordered[index].from_stance_revision !== expectedFrom) {
119
+ errors.push(`local_stance corrections 必须形成从 1 开始的连续 revision 链`);
120
+ break;
121
+ }
122
+ }
123
+ if (record.stance_revision !== ordered.length + 1) {
124
+ errors.push(`stance_revision=${record.stance_revision} 与 local_stance corrections 数量不一致(应为 ${ordered.length + 1})`);
125
+ }
126
+ }
127
+
128
+ function validateCandidates(versionDir, intentId, record, errors) {
129
+ if (!Array.isArray(record.candidates)) {
130
+ errors.push('candidates 必须是数组');
131
+ return new Map();
132
+ }
133
+ const candidates = new Map();
134
+ for (const [index, candidate] of record.candidates.entries()) {
135
+ const prefix = `candidates[${index}]`;
136
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
137
+ errors.push(`${prefix} 必须是对象`);
138
+ continue;
139
+ }
140
+ pushText(candidate.id, `${prefix}.id`, errors);
141
+ if (typeof candidate.id === 'string' && candidate.id.trim()) {
142
+ if (candidates.has(candidate.id)) errors.push(`candidate id 重复: ${candidate.id}`);
143
+ candidates.set(candidate.id, candidate);
144
+ }
145
+ if (!Number.isInteger(candidate.stance_revision) || candidate.stance_revision < 1 || candidate.stance_revision > record.stance_revision) {
146
+ errors.push(`${prefix}.stance_revision 必须位于 1..${record.stance_revision}`);
147
+ }
148
+ if (candidate.stance_revision !== record.stance_revision
149
+ && candidate.archived !== true
150
+ && candidate.requalified_for_stance_revision !== record.stance_revision) {
151
+ errors.push(`${prefix} 来自旧 Stance;必须 archived=true 或 requalified_for_stance_revision=${record.stance_revision}`);
152
+ }
153
+ pushText(candidate.mechanism, `${prefix}.mechanism`, errors);
154
+ if (!Array.isArray(candidate.artifact_refs) || candidate.artifact_refs.length === 0) {
155
+ errors.push(`${prefix}.artifact_refs 不能为空`);
156
+ } else {
157
+ candidate.artifact_refs.forEach((ref, refIndex) => artifactRef(versionDir, intentId, ref, `${prefix}.artifact_refs[${refIndex}]`, errors));
158
+ }
159
+ if (!['passed', 'failed', 'pending'].includes(candidate.floor_check)) {
160
+ errors.push(`${prefix}.floor_check 必须是 passed|failed|pending`);
161
+ }
162
+ if (candidate.floor_check === 'passed') pushText(candidate.floor_evidence, `${prefix}.floor_evidence`, errors);
163
+ }
164
+ return candidates;
165
+ }
166
+
167
+ function validateDiversityAxes(record, errors) {
168
+ if (!Array.isArray(record.diversity_axes)) {
169
+ errors.push('diversity_axes 必须是数组');
170
+ return new Set();
171
+ }
172
+ if (!['draft', 'blocked'].includes(record.status) && record.diversity_axes.length < 2) {
173
+ errors.push('Atelier 探索至少需要两个 diversity_axes');
174
+ }
175
+ const ids = new Set();
176
+ for (const [index, axis] of record.diversity_axes.entries()) {
177
+ const prefix = `diversity_axes[${index}]`;
178
+ if (!axis || typeof axis !== 'object' || Array.isArray(axis)) {
179
+ errors.push(`${prefix} 必须是对象`);
180
+ continue;
181
+ }
182
+ pushText(axis.id, `${prefix}.id`, errors);
183
+ if (typeof axis.id === 'string' && axis.id.trim()) {
184
+ if (ids.has(axis.id)) errors.push(`diversity axis id 重复: ${axis.id}`);
185
+ ids.add(axis.id);
186
+ }
187
+ pushText(axis.low, `${prefix}.low`, errors);
188
+ pushText(axis.high, `${prefix}.high`, errors);
189
+ pushText(axis.why, `${prefix}.why`, errors);
190
+ if (typeof axis.low === 'string' && typeof axis.high === 'string' && axis.low.trim() === axis.high.trim()) {
191
+ errors.push(`${prefix}.low 与 high 必须形成真实差异`);
192
+ }
193
+ }
194
+ return ids;
195
+ }
196
+
197
+ function validateSelection(versionDir, intentId, record, candidates, errors) {
198
+ if (!record.selection || typeof record.selection !== 'object' || Array.isArray(record.selection)) {
199
+ errors.push('selection 必须是对象');
200
+ return;
201
+ }
202
+ const selection = record.selection;
203
+ if (!['pending', 'selected', 'baseline_retained'].includes(selection.status)) {
204
+ errors.push('selection.status 必须是 pending|selected|baseline_retained');
205
+ }
206
+ if (!['selected', 'baseline_retained'].includes(record.status) && selection.status !== 'pending') {
207
+ errors.push(`Record status=${record.status} 时 selection.status 必须为 pending`);
208
+ }
209
+ if (selection.status === 'pending' && selection.selected_candidate !== null) {
210
+ errors.push('selection.status=pending 时 selected_candidate 必须为 null');
211
+ }
212
+ const candidateCount = Array.isArray(record.candidates) ? record.candidates.length : 0;
213
+ if (['compared', 'selected'].includes(record.status) && candidateCount < 2) {
214
+ errors.push(`${record.status} 状态至少需要两个机制不同候选`);
215
+ }
216
+ if (record.status === 'selected') {
217
+ if (selection.status !== 'selected') errors.push('Record status=selected 时 selection.status 必须为 selected');
218
+ const selected = candidates.get(selection.selected_candidate);
219
+ if (!selected) errors.push('selection.selected_candidate 必须引用存在的候选');
220
+ else if (selected.floor_check !== 'passed') errors.push('selected candidate 必须通过 Reliability Floor');
221
+ else if (selected.stance_revision !== record.stance_revision
222
+ && selected.requalified_for_stance_revision !== record.stance_revision) {
223
+ errors.push('selected candidate 必须属于当前 Stance 或已重新资格检查');
224
+ }
225
+ }
226
+ if (record.status === 'baseline_retained' && selection.status !== 'baseline_retained') {
227
+ errors.push('Record status=baseline_retained 时 selection.status 必须为 baseline_retained');
228
+ }
229
+ if (record.status === 'baseline_retained' && candidateCount < 1) {
230
+ errors.push('baseline_retained 至少需要一个接受过比较的候选');
231
+ }
232
+ if (record.status === 'baseline_retained' && selection.selected_candidate !== null) {
233
+ errors.push('baseline_retained 时 selection.selected_candidate 必须为 null');
234
+ }
235
+ if (['selected', 'baseline_retained'].includes(record.status)) {
236
+ pushText(selection.method, 'selection.method', errors);
237
+ pushText(selection.why, 'selection.why', errors);
238
+ pushText(selection.remaining_tradeoff, 'selection.remaining_tradeoff', errors);
239
+ if (!Array.isArray(selection.evidence_refs) || selection.evidence_refs.length === 0) {
240
+ errors.push('selection.evidence_refs 不能为空');
241
+ } else {
242
+ selection.evidence_refs.forEach((ref, index) => artifactRef(versionDir, intentId, ref, `selection.evidence_refs[${index}]`, errors));
243
+ }
244
+ }
245
+ }
246
+
247
+ export function validateAtelierRecord(versionDir, intentId, record = null) {
248
+ const intent = getIntent(versionDir, intentId);
249
+ if ((intent.quality_strategy ?? 'adaptive') !== 'atelier') {
250
+ throw new Error(`${intentId} 未启用 quality_strategy=atelier`);
251
+ }
252
+ const path = recordPath(versionDir, intentId);
253
+ const data = record ?? readJsonFile(path, 'Atelier Record');
254
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
255
+ throw new Error('Atelier Record 校验失败:\n - 根节点必须是对象');
256
+ }
257
+ const errors = [];
258
+ if (!data._meta || typeof data._meta !== 'object' || Array.isArray(data._meta)) {
259
+ errors.push('_meta 必须是对象');
260
+ } else {
261
+ if (data._meta._version !== '1.0') errors.push('_meta._version 必须是 1.0');
262
+ if (data._meta._loom_version !== basename(versionDir)) {
263
+ errors.push(`_meta._loom_version 必须是 ${basename(versionDir)}`);
264
+ }
265
+ }
266
+ if (data.intent_id !== intentId) errors.push(`intent_id 必须是 ${intentId}`);
267
+ if (data.intent_revision !== (intent.revision ?? 1)) {
268
+ errors.push(`intent_revision=${data.intent_revision} 已过期;当前为 ${intent.revision ?? 1}`);
269
+ }
270
+ if (!VALID_STATUS.includes(data.status)) errors.push(`status 必须是 ${VALID_STATUS.join('|')}`);
271
+ if (!Number.isInteger(data.stance_revision) || data.stance_revision < 1) errors.push('stance_revision 必须是正整数');
272
+ validateStance(data, errors);
273
+ if (!data.baseline || typeof data.baseline !== 'object' || Array.isArray(data.baseline)) {
274
+ errors.push('baseline 必须是对象');
275
+ } else if (!['draft', 'blocked'].includes(data.status)) {
276
+ pushText(data.baseline.observed_limit, 'baseline.observed_limit', errors);
277
+ if (!Array.isArray(data.baseline.artifact_refs) || data.baseline.artifact_refs.length === 0) {
278
+ errors.push('baseline.artifact_refs 不能为空');
279
+ } else {
280
+ data.baseline.artifact_refs.forEach((ref, index) => artifactRef(versionDir, intentId, ref, `baseline.artifact_refs[${index}]`, errors));
281
+ }
282
+ }
283
+ if (data.status === 'blocked') {
284
+ if (!data.blocker || typeof data.blocker !== 'object' || Array.isArray(data.blocker)) {
285
+ errors.push('blocked 状态必须提供 blocker 对象');
286
+ } else {
287
+ pushText(data.blocker.reason, 'blocker.reason', errors);
288
+ pushText(data.blocker.recovery_condition, 'blocker.recovery_condition', errors);
289
+ }
290
+ } else if (data.blocker !== null && data.blocker !== undefined) {
291
+ errors.push('非 blocked 状态的 blocker 必须为 null 或省略');
292
+ }
293
+ validateDiversityAxes(data, errors);
294
+ validateCorrections(versionDir, intentId, data, errors);
295
+ const candidates = validateCandidates(versionDir, intentId, data, errors);
296
+ validateSelection(versionDir, intentId, data, candidates, errors);
297
+ if (errors.length) throw new Error(`Atelier Record 校验失败:\n - ${errors.join('\n - ')}`);
298
+ return {
299
+ valid: true,
300
+ intent_id: intentId,
301
+ intent_revision: data.intent_revision,
302
+ stance_revision: data.stance_revision,
303
+ status: data.status,
304
+ path,
305
+ };
306
+ }
307
+
308
+ export function getAtelierRecord(versionDir, intentId) {
309
+ const path = recordPath(versionDir, intentId);
310
+ if (!existsSync(path)) throw new Error(`Atelier Record 不存在: ${path}`);
311
+ const record = readJsonFile(path, 'Atelier Record');
312
+ validateAtelierRecord(versionDir, intentId, record);
313
+ return record;
314
+ }
315
+
316
+ export function initAtelierRecord(versionDir, intentId) {
317
+ const intent = getIntent(versionDir, intentId);
318
+ if ((intent.quality_strategy ?? 'adaptive') !== 'atelier') {
319
+ throw new Error(`${intentId} 未启用 quality_strategy=atelier;由 Architect 先声明质量契约、创作空间和策略`);
320
+ }
321
+ const path = recordPath(versionDir, intentId);
322
+ if (existsSync(path)) throw new Error(`Atelier Record 已存在,不会覆盖: ${path}`);
323
+ const templatePath = join(getLoomRoot(), 'templates', 'ATELIER_RECORD_TEMPLATE.json');
324
+ const record = JSON.parse(readFileSync(templatePath, 'utf-8'));
325
+ record._meta._loom_version = basename(versionDir);
326
+ record.intent_id = intentId;
327
+ record.intent_revision = intent.revision ?? 1;
328
+ mkdirSync(join(versionDir, '09_ATELIER', 'files', intentId), { recursive: true });
329
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf-8', flag: 'wx' });
330
+ return record;
331
+ }