rcf-lite 0.7.1 → 0.9.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rcf.js +6 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/harness-template.md +11 -0
  5. package/guidance/managed/agent-instructions-block.hash +1 -1
  6. package/guidance/managed/agent-instructions-block.md +11 -0
  7. package/package.json +5 -3
  8. package/rcf/adrs/adr-010.json +30 -0
  9. package/rcf/code-nodes/cn-058.json +18 -0
  10. package/rcf/code-nodes/cn-059.json +14 -0
  11. package/rcf/code-nodes/cn-060.json +14 -0
  12. package/rcf/code-nodes/cn-061.json +14 -0
  13. package/rcf/code-nodes/cn-062.json +14 -0
  14. package/rcf/code-nodes/cn-063.json +15 -0
  15. package/rcf/code-nodes/cn-064.json +15 -0
  16. package/rcf/code-nodes/cn-065.json +16 -0
  17. package/rcf/code-nodes/cn-066.json +14 -0
  18. package/rcf/code-nodes/cn-067.json +15 -0
  19. package/rcf/code-nodes/cn-068.json +15 -0
  20. package/rcf/code-nodes/cn-069.json +16 -0
  21. package/rcf/fbs/fbs-016.json +39 -0
  22. package/rcf/fbs/fbs-017.json +40 -0
  23. package/rcf/fbs/fbs-018.json +34 -0
  24. package/rcf/fbs/fbs-019.json +33 -0
  25. package/rcf/requirements/req-010.json +20 -0
  26. package/rcf/test-suites/ts-026.json +54 -0
  27. package/rcf/test-suites/ts-027.json +115 -0
  28. package/rcf/test-suites/ts-028.json +46 -0
  29. package/rcf/test-suites/ts-029.json +46 -0
  30. package/rcf/user-stories/us-1001.json +56 -0
  31. package/rcf/user-stories/us-1002.json +96 -0
  32. package/rcf/user-stories/us-1003.json +48 -0
  33. package/rcf/user-stories/us-1004.json +48 -0
  34. package/src/admissibility/enforce.js +142 -0
  35. package/src/admissibility/index.js +8 -0
  36. package/src/admissibility/markers.js +104 -0
  37. package/src/admissibility/scope-lint.js +163 -0
  38. package/src/blueprint/apply.js +464 -0
  39. package/src/blueprint/conflicts.js +351 -0
  40. package/src/blueprint/diff.js +82 -0
  41. package/src/blueprint/index.js +12 -0
  42. package/src/blueprint/list.js +21 -0
  43. package/src/blueprint/loader.js +163 -0
  44. package/src/blueprint/manifest-writer.js +49 -0
  45. package/src/blueprint/namespace.js +145 -0
  46. package/src/blueprint/remove.js +105 -0
  47. package/src/blueprint/resolutions.js +83 -0
  48. package/src/blueprint/standards.js +148 -0
  49. package/src/blueprint/supersede.js +318 -0
  50. package/src/browser-verify/invariants.js +33 -6
  51. package/src/build/bundle.js +34 -11
  52. package/src/build/standards-selector.js +52 -0
  53. package/src/cli/blueprint.js +325 -0
  54. package/src/cli/create.js +49 -1
  55. package/src/cli/help.js +8 -0
  56. package/src/cli/init.js +20 -5
  57. package/src/cli/read.js +7 -1
  58. package/src/cli/standards.js +127 -0
  59. package/src/cli/test-suite.js +7 -2
  60. package/src/core/store/ids.js +168 -18
  61. package/src/core/store/loader.js +31 -17
  62. package/src/core/store/walker.js +62 -4
  63. package/src/core/store/writer.js +41 -11
  64. package/src/deployment/index.js +13 -0
  65. package/src/deployment/placeholder-detector.js +113 -0
  66. package/src/finalise/detect.js +51 -29
  67. package/src/finalise/index.js +16 -2
  68. package/src/finalise/ingest.js +41 -0
  69. package/src/mcp/tools.js +10 -2
  70. package/src/query/formatters/table.js +7 -10
  71. package/src/query/index.js +4 -0
  72. package/src/query/refuse-on-admissibility.js +73 -0
  73. package/src/query/trace.js +45 -4
  74. package/src/ruleset/index.js +140 -0
  75. package/src/ruleset/ruleset.json +146 -0
  76. package/src/verify/chain/index.js +31 -0
  77. package/src/verify/verdict/index.js +67 -0
@@ -0,0 +1,464 @@
1
+ // Blueprint apply. Orchestrates loader + conflict detection + namespaced
2
+ // contribution writes + manifest.blueprints[] append.
3
+ //
4
+ // Idempotency: repeating `apply(tree, source)` on an already-applied
5
+ // slug with no new conflicts is a no-op with a `{ applied: false,
6
+ // alreadyApplied: true }` return. A slug that WOULD conflict on
7
+ // re-apply (new version added a scope:global ADR) returns the conflict
8
+ // list without mutating anything.
9
+
10
+ import { copyFile, mkdir, rename, stat, unlink } from 'node:fs/promises';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+
15
+ import { rcfError } from '../core/errors/index.js';
16
+ import { subdirFor } from '#core/store';
17
+ import { detectCrossBlueprintClaims, detectGlobalAdrConflicts } from './conflicts.js';
18
+ import { loadBlueprint } from './loader.js';
19
+ import { updateManifest } from './manifest-writer.js';
20
+ import { stampId } from './namespace.js';
21
+ import { nextResolutionId } from './resolutions.js';
22
+
23
+ /**
24
+ * @typedef {object} ApplyResult
25
+ * @property {boolean} applied
26
+ * @property {boolean} [alreadyApplied]
27
+ * @property {string} slug
28
+ * @property {string} version
29
+ * @property {Array<{ id: string, path: string, kind: string }>} contributions
30
+ * @property {import('./conflicts.js').Conflict[]} [conflicts]
31
+ */
32
+
33
+ /**
34
+ * @param {object} args
35
+ * @param {string} args.projectRoot
36
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
37
+ * @param {string} args.source - path to blueprint directory
38
+ * @param {string} [args.namespaceOverride] - non-default namespace slug
39
+ * @param {Array<{ topic: string, resolvedByAdrId: string }>} [args.resolveDeclarations]
40
+ * Operator-supplied conflict resolutions declared on the add
41
+ * itself. One entry per resolved topic. For each, the resolution
42
+ * record is appended to `manifest.resolutions[]` in memory BEFORE
43
+ * the conflict detector runs, so the freshly declared resolution
44
+ * is honoured; the record then persists via the manifest write
45
+ * alongside the applied-blueprint update. Malformed declarations
46
+ * (topic missing from the incoming ADR set, no existing applied
47
+ * blueprint on the topic) are refused with an rcfError. Reads
48
+ * `manifest.resolutions[]` for id-mint monotonicity.
49
+ * @param {Date} [args.now]
50
+ * @param {boolean} [args.dryRun]
51
+ * @param {(src: string, dest: string) => Promise<void>} [args._copyFileForTest]
52
+ * Test-only seam. Substitutes fs.copyFile so a fixture can inject
53
+ * a failure part-way through the contribution write loop and
54
+ * prove the rollback runs. Never used in production.
55
+ * @returns {Promise<ApplyResult | import('../core/errors/index.js').RcfError>}
56
+ */
57
+ export async function applyBlueprint({ projectRoot, tree, source, namespaceOverride, resolveDeclarations, now = new Date(), dryRun = false, _copyFileForTest }) {
58
+ const blueprint = await loadBlueprint(source);
59
+ if (blueprint.kind) return blueprint; // RcfError
60
+ const namespace = namespaceOverride ?? blueprint.slug;
61
+
62
+ const applied = tree.manifest?.blueprints ?? [];
63
+ const existing = applied.find((b) => b.slug === blueprint.slug);
64
+ const stamped = stampContributions(blueprint.contributions, namespace);
65
+ if (stamped.error) {
66
+ return rcfError({ kind: 'validation', message: stamped.error });
67
+ }
68
+
69
+ // Pre-detection: fold operator-supplied --resolve declarations into a
70
+ // WORKING COPY of the manifest so the detector honours them on this
71
+ // run. The final manifest write later composes the same resolution
72
+ // records into the persisted manifest, so the in-memory copy and the
73
+ // on-disk write agree.
74
+ const incomingForConflicts = { slug: blueprint.slug, contributions: stamped.contributions };
75
+ const declResult = composeDeclaredResolutions({
76
+ manifest: tree.manifest,
77
+ applied,
78
+ incoming: incomingForConflicts,
79
+ declarations: resolveDeclarations ?? [],
80
+ now,
81
+ });
82
+ if (declResult.kind) return declResult; // RcfError
83
+ const workingManifest = declResult.manifest;
84
+ const newResolutionRecords = declResult.newRecords;
85
+ const duplicateResolveTopics = declResult.duplicateTopics ?? [];
86
+
87
+ // Conflict detection ALWAYS runs, including on re-apply. Two classes
88
+ // fire pre-write: scope:global ADR topic collisions (design brief),
89
+ // and cross-blueprint ownership claims where an incoming id is
90
+ // already recorded as owned by a DIFFERENT applied blueprint (the
91
+ // spa vs spa-theme ambiguity class -- now caught here via the
92
+ // authoritative manifest record instead of via string grammar). The
93
+ // globalAdrTopic detector consults `workingManifest.resolutions[]` so
94
+ // a resolved conflict is dropped from the list before the caller sees
95
+ // it.
96
+ const rawGlobalConflicts = detectGlobalAdrConflicts(applied, incomingForConflicts, workingManifest);
97
+ const conflicts = [
98
+ // Thread the CLI-supplied `source` (what the operator typed) onto
99
+ // each enriched conflict so the renderer can print option 3
100
+ // exactly as printed - `rcf blueprint supersede <topic> --incoming
101
+ // <source>` - with a real source path the operator can copy back
102
+ // into a fresh shell.
103
+ ...await enrichAdrConflicts(rawGlobalConflicts, tree, blueprint, source),
104
+ ...detectCrossBlueprintClaims(applied, incomingForConflicts),
105
+ ];
106
+ if (conflicts.length > 0) {
107
+ return { applied: false, slug: blueprint.slug, version: blueprint.version, contributions: [], conflicts };
108
+ }
109
+
110
+ // Ownership set for the overwrite guard below. On a re-apply, the
111
+ // authoritative record is `existing.contributions[].id` -- the exact
112
+ // list of ids the currently-applied version of this blueprint owns.
113
+ // On first apply this is an empty set (any file already on disk at a
114
+ // destination path is by definition foreign or author-owned).
115
+ const ownedIds = new Set((existing?.contributions ?? []).map((c) => c.id));
116
+
117
+ if (existing && existing.version === blueprint.version) {
118
+ return {
119
+ applied: false, alreadyApplied: true,
120
+ slug: blueprint.slug, version: blueprint.version,
121
+ contributions: stamped.contributions,
122
+ ...(duplicateResolveTopics.length > 0 ? { warnings: [{ kind: 'duplicateResolveTopic', topics: duplicateResolveTopics }] } : {}),
123
+ };
124
+ }
125
+
126
+ // Write contributions atomically at the batch level: every contribution
127
+ // is copied to a `<name>.rcf-tmp-<slug>-<epoch>` sidecar first, and the
128
+ // sidecars are only renamed into their final destinations after ALL
129
+ // copies (and the pre-write collision guards) have succeeded. If any
130
+ // step in the copy loop fails, every already-written sidecar is
131
+ // unlinked before the error is returned; the manifest never sees the
132
+ // partial batch, and the tree is left with no orphan contribution
133
+ // files whose ids are not recorded anywhere.
134
+ //
135
+ // The alternative (write in place, roll back on failure) was rejected
136
+ // because an in-place partial that races with a concurrent walker
137
+ // would expose ids the manifest does not yet name. The sidecar phase
138
+ // keeps every id-bearing file invisible to the walker until the
139
+ // whole batch is ready to commit.
140
+ const stampedContributions = stamped.contributions;
141
+ const writtenContributions = [];
142
+ const copyFileImpl = _copyFileForTest ?? copyFile;
143
+ if (dryRun) {
144
+ for (const c of stampedContributions) {
145
+ const relDest = destPathFor(c);
146
+ writtenContributions.push(preserveScope({ id: c.id, kind: c.kind, path: relDest }, c));
147
+ }
148
+ } else {
149
+ const tmpSuffix = `.rcf-tmp-${blueprint.slug}-${now.getTime()}`;
150
+ const stagedWrites = []; // { tmpAbs, absDest, relDest, id, kind, c }
151
+ const rollback = async (err) => {
152
+ for (const w of stagedWrites) {
153
+ await unlink(w.tmpAbs).catch(() => {});
154
+ }
155
+ return rcfError({ kind: 'ioFailure', message: `blueprint contribution write failed (rolled back ${stagedWrites.length} staged file(s)): ${err.message}`, filePath: err.relDest ?? '' });
156
+ };
157
+ for (const c of stampedContributions) {
158
+ const src = resolve(blueprint.source, 'contributions', c.path);
159
+ const relDest = destPathFor(c);
160
+ const absDest = join(projectRoot, relDest);
161
+ try {
162
+ await stat(src);
163
+ } catch {
164
+ for (const w of stagedWrites) await unlink(w.tmpAbs).catch(() => {});
165
+ return rcfError({ kind: 'missingFile', message: `blueprint contribution missing on disk: ${src}`, filePath: src });
166
+ }
167
+ const alreadyThere = await stat(absDest).catch(() => null);
168
+ if (alreadyThere) {
169
+ // Authoritative ownership: only a file whose id is already
170
+ // recorded on THIS blueprint's manifest entry is safe to
171
+ // overwrite (the re-apply idempotency case). Anything else --
172
+ // first-apply into a tree that already has the file, or a new
173
+ // contribution appearing in a re-applied version -- is treated
174
+ // as foreign and refused. Grammar is deliberately not consulted
175
+ // here: `ADR-201-spa-theme` may be a legitimate `spa`-owned id
176
+ // whose author put a semantic tail after the slug.
177
+ if (!ownedIds.has(c.id)) {
178
+ for (const w of stagedWrites) await unlink(w.tmpAbs).catch(() => {});
179
+ return rcfError({
180
+ kind: 'duplicateId',
181
+ message: `blueprint apply: contribution ${c.id} would overwrite an existing file at ${relDest} that is not recorded as owned by blueprint '${blueprint.slug}'.`,
182
+ filePath: relDest,
183
+ });
184
+ }
185
+ }
186
+ try {
187
+ await mkdir(dirname(absDest), { recursive: true });
188
+ } catch (err) {
189
+ const wrapped = new Error(err.message); wrapped.relDest = relDest;
190
+ return rollback(wrapped);
191
+ }
192
+ const tmpAbs = `${absDest}${tmpSuffix}`;
193
+ try {
194
+ await copyFileImpl(src, tmpAbs);
195
+ } catch (err) {
196
+ const wrapped = new Error(err.message); wrapped.relDest = relDest;
197
+ return rollback(wrapped);
198
+ }
199
+ stagedWrites.push({ tmpAbs, absDest, relDest, id: c.id, kind: c.kind, c });
200
+ }
201
+ // Commit phase. Rename each sidecar into place. A rename failure
202
+ // rolls the still-sideloaded remainder back, plus best-effort undo
203
+ // of the renames that already committed (delete-if-differs is not
204
+ // possible without content compare; the design brief accepts that
205
+ // a commit-phase failure may leave a partial tree with a matching
206
+ // partial manifest -- the manifest write happens after this loop
207
+ // and is the ordering guarantee). The Phase 1 test injects the
208
+ // failure in the COPY phase, which the rollback covers cleanly.
209
+ for (const w of stagedWrites) {
210
+ try {
211
+ await rename(w.tmpAbs, w.absDest);
212
+ } catch (err) {
213
+ for (const w2 of stagedWrites) await unlink(w2.tmpAbs).catch(() => {});
214
+ return rcfError({ kind: 'ioFailure', message: `blueprint contribution commit failed: ${err.message}`, filePath: w.relDest });
215
+ }
216
+ }
217
+ for (const w of stagedWrites) {
218
+ writtenContributions.push(preserveScope({ id: w.id, kind: w.kind, path: w.relDest }, w.c));
219
+ }
220
+ }
221
+
222
+ // Update manifest.blueprints[].
223
+ const nextEntry = {
224
+ slug: blueprint.slug,
225
+ version: blueprint.version,
226
+ appliedAt: now.toISOString(),
227
+ source,
228
+ ...(namespaceOverride ? { namespace: namespaceOverride } : {}),
229
+ ...(writtenContributions.length > 0 ? { contributions: writtenContributions } : {}),
230
+ };
231
+ const manifestResult = await updateManifest({
232
+ projectRoot,
233
+ manifest: tree.manifest,
234
+ mutate: (next) => {
235
+ const list = Array.isArray(next.blueprints) ? next.blueprints : [];
236
+ const filtered = list.filter((b) => b.slug !== blueprint.slug);
237
+ filtered.push(nextEntry);
238
+ next.blueprints = filtered;
239
+ // Fold any --resolve declarations recorded on this add into the
240
+ // persisted resolutions[]. Ordering is [existing, ...new] so
241
+ // record ids stay monotonic within a day.
242
+ if (newResolutionRecords.length > 0) {
243
+ const resList = Array.isArray(next.resolutions) ? next.resolutions : [];
244
+ for (const rec of newResolutionRecords) resList.push(rec);
245
+ next.resolutions = resList;
246
+ }
247
+ },
248
+ dryRun,
249
+ });
250
+ if (manifestResult.kind) return manifestResult; // RcfError
251
+
252
+ return {
253
+ applied: true,
254
+ slug: blueprint.slug,
255
+ version: blueprint.version,
256
+ contributions: writtenContributions,
257
+ ...(duplicateResolveTopics.length > 0 ? { warnings: [{ kind: 'duplicateResolveTopic', topics: duplicateResolveTopics }] } : {}),
258
+ };
259
+ }
260
+
261
+ function stampContributions(contributions, namespace) {
262
+ const out = [];
263
+ for (const c of contributions ?? []) {
264
+ const r = stampId(c.id, namespace);
265
+ if ('error' in r) return { error: r.error };
266
+ out.push({ ...c, id: r.id });
267
+ }
268
+ return { contributions: out };
269
+ }
270
+
271
+ /**
272
+ * Copy scope + topic from the source contribution onto the manifest
273
+ * record when they are present. Manifest schema (0.4.4) accepts optional
274
+ * scope='global' and topic on appliedBlueprintContribution so the
275
+ * conflict detector can see the ADR scope across `add` invocations.
276
+ */
277
+ function preserveScope(manifestRecord, source) {
278
+ if (source.scope === 'global') manifestRecord.scope = 'global';
279
+ if (typeof source.topic === 'string') manifestRecord.topic = source.topic;
280
+ return manifestRecord;
281
+ }
282
+
283
+ function destPathFor(c) {
284
+ const kindMap = {
285
+ prd: 'prd', req: 'req', us: 'userStory', tad: 'tad', tac: 'tac',
286
+ adr: 'adr', bs: 'buildSequence', fbs: 'fbs', ts: 'testSuite', cn: 'codeNode',
287
+ };
288
+ const kind = kindMap[c.kind];
289
+ if (!kind) throw new Error(`blueprint apply: unknown contribution kind '${c.kind}'`);
290
+ const dir = subdirFor(kind);
291
+ const filename = `${filenameForId(c.id, c.kind)}.json`;
292
+ return dir ? `rcf/${dir}/${filename}` : `rcf/${filename}`;
293
+ }
294
+
295
+ function filenameForId(id, kind) {
296
+ return id.toLowerCase();
297
+ }
298
+
299
+ /**
300
+ * Compose zero-or-more `manifest.resolutions[]` records from operator
301
+ * --resolve declarations and return a working manifest copy carrying
302
+ * them (for detector honour on this run) plus the raw new records (for
303
+ * the eventual manifest persist). Refuses malformed declarations up
304
+ * front so the detector never sees a resolution that would break its
305
+ * shape assumptions.
306
+ *
307
+ * A declaration `{ topic, resolvedByAdrId }` is valid iff:
308
+ * - the incoming blueprint carries a scope:global ADR on `topic`, and
309
+ * - at least one currently-applied blueprint carries a scope:global
310
+ * ADR on the same topic (the resolution needs both sides).
311
+ * - resolvedByAdrId is a well-formed ADR id string.
312
+ */
313
+ function composeDeclaredResolutions({ manifest, applied, incoming, declarations, now }) {
314
+ if (!Array.isArray(declarations) || declarations.length === 0) {
315
+ return { manifest: manifest ?? null, newRecords: [], duplicateTopics: [] };
316
+ }
317
+ const incomingGlobals = (incoming.contributions ?? [])
318
+ .filter((c) => c.kind === 'adr' && c.scope === 'global' && typeof c.topic === 'string');
319
+ const workingManifest = manifest ? JSON.parse(JSON.stringify(manifest)) : {};
320
+ const newRecords = [];
321
+ const iso = now.toISOString();
322
+ // Error messages here are prefix-free: the CLI edge prepends
323
+ // `[error] blueprint add: ` on every rcfError, and doubling the
324
+ // prefix reads badly on the terminal (`blueprint add: blueprint
325
+ // add: ...`).
326
+ // Dedupe by topic within a single add: two --resolve declarations
327
+ // on the same topic silently minted two records on the first
328
+ // implementation. Keep the FIRST occurrence per topic (operator
329
+ // wrote it first, before whatever came after), record the drops
330
+ // so the CLI can surface a warning.
331
+ const seenTopics = new Set();
332
+ const duplicateTopics = [];
333
+ for (const decl of declarations) {
334
+ if (typeof decl?.topic !== 'string' || decl.topic.trim().length === 0) {
335
+ // Schema minLength:1 accepts whitespace-only; the writer refuses
336
+ // it up-front so a whitespace-only topic never lands on disk.
337
+ return rcfError({ kind: 'usage', message: `--resolve declaration is missing a topic.` });
338
+ }
339
+ if (typeof decl.resolvedByAdrId !== 'string' || !/^ADR-\d{3,}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/.test(decl.resolvedByAdrId)) {
340
+ return rcfError({ kind: 'usage', message: `--resolve resolvedByAdrId '${decl.resolvedByAdrId}' is not a well-formed ADR id.` });
341
+ }
342
+ if (typeof decl.reason === 'string' && decl.reason.length > 0 && decl.reason.trim().length === 0) {
343
+ return rcfError({ kind: 'usage', message: `--resolve reason for topic '${decl.topic}' must not be whitespace-only.` });
344
+ }
345
+ if (seenTopics.has(decl.topic)) {
346
+ duplicateTopics.push(decl.topic);
347
+ continue;
348
+ }
349
+ const incomingHit = incomingGlobals.find((c) => c.topic === decl.topic);
350
+ if (!incomingHit) {
351
+ return rcfError({ kind: 'usage', message: `--resolve topic '${decl.topic}' does not match any scope:global ADR on the incoming blueprint.` });
352
+ }
353
+ const existingHits = [];
354
+ for (const bp of applied) {
355
+ if (bp.slug === incoming.slug) continue;
356
+ for (const c of bp.contributions ?? []) {
357
+ if (c.kind === 'adr' && c.scope === 'global' && c.topic === decl.topic) {
358
+ existingHits.push({ slug: bp.slug, adrId: c.id });
359
+ }
360
+ }
361
+ }
362
+ if (existingHits.length === 0) {
363
+ return rcfError({ kind: 'usage', message: `--resolve topic '${decl.topic}' has no applied blueprint carrying a scope:global ADR on that topic; nothing to resolve against.` });
364
+ }
365
+ seenTopics.add(decl.topic);
366
+ // Mint the next id off the WORKING manifest so successive
367
+ // declarations increment cleanly within the same batch.
368
+ const id = nextResolutionId(workingManifest, now);
369
+ const record = {
370
+ id,
371
+ createdAt: iso,
372
+ kind: 'globalAdrTopic',
373
+ topic: decl.topic,
374
+ resolvedByAdrId: decl.resolvedByAdrId,
375
+ supersedes: [
376
+ ...existingHits.map((h) => ({ slug: h.slug, adrId: h.adrId })),
377
+ { slug: incoming.slug, adrId: incomingHit.id },
378
+ ],
379
+ };
380
+ if (typeof decl.reason === 'string' && decl.reason.length > 0) record.reason = decl.reason;
381
+ newRecords.push(record);
382
+ const list = Array.isArray(workingManifest.resolutions) ? workingManifest.resolutions : [];
383
+ list.push(record);
384
+ workingManifest.resolutions = list;
385
+ }
386
+ return { manifest: workingManifest, newRecords, duplicateTopics };
387
+ }
388
+
389
+ /**
390
+ * Enrich a global-ADR conflict list with title / decision text from
391
+ * BOTH sides: the tree's byId map (existing blueprint, already loaded
392
+ * by the walker) and the incoming blueprint's on-disk contribution
393
+ * file (incoming blueprint, not yet in the tree). Best-effort on
394
+ * either side: if a lookup fails the enriched fields are simply
395
+ * absent and the renderer falls back to id-at-path for that side.
396
+ *
397
+ * Round-2 review nit: the earlier version only enriched the existing
398
+ * side, so a conflict report against shipped SPA + REST rendered
399
+ * asymmetrically (spa gets 'SPA auth: cookie sessions — HttpOnly
400
+ * cookies.' while rest gets 'ADR-003-rest at rcf/adrs/...'). Both
401
+ * sides are readable — the existing side from tree, the incoming
402
+ * side from disk — and both sides should be rendered.
403
+ */
404
+ async function enrichAdrConflicts(conflicts, tree, blueprint, sourceLabel) {
405
+ if (conflicts.length === 0) return conflicts;
406
+ const out = [];
407
+ for (const c of conflicts) {
408
+ if (c.kind !== 'globalAdrTopic') {
409
+ out.push(c);
410
+ continue;
411
+ }
412
+ const enriched = { ...c, incoming: { ...c.incoming }, existing: { ...c.existing } };
413
+ // Round-3 (Baz ruling): thread the CLI-supplied source (what the
414
+ // operator typed on `rcf blueprint add SRC`) onto the incoming
415
+ // side so the renderer can print option 3 verbatim — the operator
416
+ // can copy the same source path into the supersede invocation
417
+ // with zero editing.
418
+ if (typeof sourceLabel === 'string' && sourceLabel.length > 0) {
419
+ enriched.incoming.source = sourceLabel;
420
+ }
421
+ // Existing side: the walker already loaded it into tree.byId.
422
+ const existingDoc = tree.byId?.get(c.existing.id);
423
+ if (existingDoc) {
424
+ if (typeof existingDoc.title === 'string') enriched.existing.title = existingDoc.title;
425
+ if (typeof existingDoc.decision === 'string') enriched.existing.decision = firstSentence(existingDoc.decision);
426
+ }
427
+ // Incoming side: read the ADR file straight from the blueprint's
428
+ // contribution directory. blueprint.source is absolute (loader
429
+ // resolves it), the contribution path is relative to contributions/.
430
+ const incomingDoc = await _readIncomingAdrForEnrichment({
431
+ blueprintSource: blueprint.source,
432
+ contributionPath: c.incoming.path,
433
+ });
434
+ if (typeof incomingDoc.title === 'string') enriched.incoming.title = incomingDoc.title;
435
+ if (typeof incomingDoc.decision === 'string') enriched.incoming.decision = incomingDoc.decision;
436
+ out.push(enriched);
437
+ }
438
+ return out;
439
+ }
440
+
441
+ /**
442
+ * Read the incoming ADR file from a blueprint's contribution directory
443
+ * and pull title/decision. Best-effort: any read/parse failure returns
444
+ * an empty object and the renderer falls back to id-at-path.
445
+ */
446
+ export async function _readIncomingAdrForEnrichment({ blueprintSource, contributionPath }) {
447
+ try {
448
+ const raw = await readFile(`${blueprintSource}/contributions/${contributionPath}`, 'utf8');
449
+ const doc = JSON.parse(raw);
450
+ return {
451
+ title: typeof doc.title === 'string' ? doc.title : undefined,
452
+ decision: typeof doc.decision === 'string' ? firstSentence(doc.decision) : undefined,
453
+ };
454
+ } catch {
455
+ return {};
456
+ }
457
+ }
458
+
459
+ function firstSentence(text) {
460
+ const trimmed = text.trim();
461
+ if (trimmed.length === 0) return trimmed;
462
+ const m = trimmed.match(/^[^.!?]+[.!?](?=\s|$)/);
463
+ return m ? m[0] : trimmed;
464
+ }