rcf-lite 0.8.0 → 0.10.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 (138) hide show
  1. package/CHANGELOG.md +124 -51
  2. package/README.md +8 -4
  3. package/bin/rcf.js +147 -53
  4. package/fixtures/canary-manifest.json +9 -9
  5. package/guidance/README.md +1 -1
  6. package/guidance/build-cycle-playbook.md +51 -51
  7. package/guidance/build-cycle.md +7 -7
  8. package/guidance/document-model.md +1 -1
  9. package/guidance/elicitation-playbook.md +29 -29
  10. package/guidance/harness-template.md +21 -10
  11. package/guidance/managed/README.md +1 -1
  12. package/guidance/managed/agent-instructions-block.hash +1 -1
  13. package/guidance/managed/agent-instructions-block.md +20 -9
  14. package/guidance/manifest.json +1 -1
  15. package/guidance/overview.md +4 -4
  16. package/package.json +5 -7
  17. package/rcf/adrs/adr-008.json +1 -1
  18. package/rcf/adrs/adr-009.json +4 -4
  19. package/rcf/adrs/adr-010.json +30 -0
  20. package/rcf/code-nodes/cn-016.json +1 -1
  21. package/rcf/code-nodes/cn-019.json +1 -1
  22. package/rcf/code-nodes/cn-020.json +1 -1
  23. package/rcf/code-nodes/cn-021.json +1 -1
  24. package/rcf/code-nodes/cn-022.json +1 -1
  25. package/rcf/code-nodes/cn-049.json +1 -1
  26. package/rcf/code-nodes/cn-055.json +1 -1
  27. package/rcf/code-nodes/cn-057.json +5 -5
  28. package/rcf/code-nodes/cn-058.json +18 -0
  29. package/rcf/code-nodes/cn-059.json +14 -0
  30. package/rcf/code-nodes/cn-060.json +14 -0
  31. package/rcf/code-nodes/cn-061.json +14 -0
  32. package/rcf/code-nodes/cn-062.json +14 -0
  33. package/rcf/code-nodes/cn-063.json +15 -0
  34. package/rcf/code-nodes/cn-064.json +15 -0
  35. package/rcf/code-nodes/cn-065.json +16 -0
  36. package/rcf/code-nodes/cn-066.json +14 -0
  37. package/rcf/code-nodes/cn-067.json +15 -0
  38. package/rcf/code-nodes/cn-068.json +15 -0
  39. package/rcf/code-nodes/cn-069.json +16 -0
  40. package/rcf/fbs/fbs-005.json +1 -1
  41. package/rcf/fbs/fbs-006.json +2 -2
  42. package/rcf/fbs/fbs-007.json +1 -1
  43. package/rcf/fbs/fbs-014.json +1 -1
  44. package/rcf/fbs/fbs-015.json +9 -8
  45. package/rcf/fbs/fbs-016.json +39 -0
  46. package/rcf/fbs/fbs-017.json +40 -0
  47. package/rcf/fbs/fbs-018.json +34 -0
  48. package/rcf/fbs/fbs-019.json +33 -0
  49. package/rcf/requirements/req-008.json +1 -1
  50. package/rcf/requirements/req-009.json +2 -2
  51. package/rcf/requirements/req-010.json +20 -0
  52. package/rcf/test-suites/PENDING.md +2 -2
  53. package/rcf/test-suites/ts-004.json +1 -1
  54. package/rcf/test-suites/ts-006.json +3 -3
  55. package/rcf/test-suites/ts-008.json +2 -2
  56. package/rcf/test-suites/ts-009.json +2 -2
  57. package/rcf/test-suites/ts-017.json +1 -1
  58. package/rcf/test-suites/ts-024.json +1 -1
  59. package/rcf/test-suites/ts-025.json +32 -18
  60. package/rcf/test-suites/ts-026.json +54 -0
  61. package/rcf/test-suites/ts-027.json +115 -0
  62. package/rcf/test-suites/ts-028.json +46 -0
  63. package/rcf/test-suites/ts-029.json +46 -0
  64. package/rcf/user-stories/us-1001.json +56 -0
  65. package/rcf/user-stories/us-1002.json +96 -0
  66. package/rcf/user-stories/us-1003.json +48 -0
  67. package/rcf/user-stories/us-1004.json +48 -0
  68. package/rcf/user-stories/us-805.json +2 -2
  69. package/rcf/user-stories/us-901.json +13 -13
  70. package/src/blueprint/apply.js +464 -0
  71. package/src/blueprint/conflicts.js +351 -0
  72. package/src/blueprint/diff.js +82 -0
  73. package/src/blueprint/index.js +12 -0
  74. package/src/blueprint/list.js +21 -0
  75. package/src/blueprint/loader.js +163 -0
  76. package/src/blueprint/manifest-writer.js +49 -0
  77. package/src/blueprint/namespace.js +145 -0
  78. package/src/blueprint/remove.js +105 -0
  79. package/src/blueprint/resolutions.js +83 -0
  80. package/src/blueprint/standards.js +148 -0
  81. package/src/blueprint/supersede.js +318 -0
  82. package/src/browser-verify/invariants.js +33 -6
  83. package/src/build/bundle.js +37 -14
  84. package/src/build/formatters/markdown.js +9 -9
  85. package/src/build/mark.js +3 -3
  86. package/src/build/queue.js +1 -1
  87. package/src/build/standards-selector.js +52 -0
  88. package/src/cli/blueprint.js +325 -0
  89. package/src/cli/browser-verify.js +1 -1
  90. package/src/cli/build.js +139 -69
  91. package/src/cli/coverage.js +1 -1
  92. package/src/cli/create.js +48 -3
  93. package/src/cli/delete.js +2 -2
  94. package/src/cli/design.js +10 -10
  95. package/src/cli/fbs.js +1 -1
  96. package/src/cli/finalise.js +22 -20
  97. package/src/cli/help.js +269 -89
  98. package/src/cli/impact.js +1 -1
  99. package/src/cli/init.js +20 -5
  100. package/src/cli/intake.js +3 -3
  101. package/src/cli/link.js +3 -3
  102. package/src/cli/preflight.js +2 -2
  103. package/src/cli/read.js +1 -1
  104. package/src/cli/req-baseline.js +2 -2
  105. package/src/cli/req-classify.js +3 -3
  106. package/src/cli/review.js +1 -1
  107. package/src/cli/standards.js +127 -0
  108. package/src/cli/test-suite.js +1 -1
  109. package/src/cli/trace.js +1 -1
  110. package/src/cli/ui-baseline.js +3 -3
  111. package/src/cli/ui-classify.js +4 -4
  112. package/src/cli/update.js +2 -2
  113. package/src/cli/validate.js +2 -2
  114. package/src/cli/view.js +12 -10
  115. package/src/core/store/ids.js +168 -18
  116. package/src/core/store/loader.js +27 -16
  117. package/src/core/store/walker.js +27 -15
  118. package/src/core/store/writer.js +1 -1
  119. package/src/deployment/index.js +13 -0
  120. package/src/deployment/placeholder-detector.js +113 -0
  121. package/src/design/writer.js +3 -3
  122. package/src/finalise/detect.js +32 -38
  123. package/src/finalise/index.js +0 -1
  124. package/src/finalise/install.js +9 -8
  125. package/src/finalise/spawn.js +14 -10
  126. package/src/mcp/tools.js +1 -1
  127. package/src/query/formatters/table.js +7 -10
  128. package/src/query/trace.js +45 -4
  129. package/src/req-baseline/gate.js +1 -1
  130. package/src/ui-baseline/manifest-writer.js +2 -2
  131. package/src/verify/cli/cleanup.js +1 -1
  132. package/src/verify/cli/mcp.js +1 -1
  133. package/src/verify/cli/provision.js +1 -1
  134. package/src/verify/cli/report.js +1 -1
  135. package/src/verify/cli/run.js +1 -1
  136. package/src/view-supervisor/manifest-writer.js +2 -2
  137. package/bin/rcf-verify.js +0 -122
  138. package/src/verify/cli/help.js +0 -56
@@ -0,0 +1,48 @@
1
+ {
2
+ "usId": "US-1004",
3
+ "prdId": "PRD-001",
4
+ "reqId": "REQ-010",
5
+ "version": "0.1.0",
6
+ "status": "draft",
7
+ "title": "Selective retrieval at bundle assembly (contextRequirements.standardIds)",
8
+ "description": "The build-context bundle assembler runs a deterministic selector during FBS bundle assembly that reads the FBS work text plus the standards manifest (tags, titles, and short summaries) and returns the subset of standard slugs relevant to the FBS. The selected slugs populate `contextRequirements.standardIds` for that bundle only. Operator override is supported by authoring the array on the FBS.",
9
+ "asA": "operator on an RCF project with standards registered",
10
+ "iWant": "the bundle assembler to select relevant standards per FBS automatically, and to honour my override when I author the array on the FBS itself",
11
+ "soThat": "the default path does not require operator authoring and the override path exists when the agentic selection needs correcting",
12
+ "acceptanceCriteria": [
13
+ {
14
+ "id": "AC-1004-1",
15
+ "description": "The bundle for an FBS with no operator-authored standardIds carries the agentically-selected subset",
16
+ "given": "a project with two standards packs (`wsd-naming` tagged `naming`, `security-baseline` tagged `security`) and an FBS whose summary names `security`",
17
+ "when": "the bundle assembler runs for that FBS with no operator-authored `standardIds`",
18
+ "then": "the bundle's `context` section carries `standardIds` including `security-baseline` and the pack's rendered summary in the standards payload",
19
+ "testable": true
20
+ },
21
+ {
22
+ "id": "AC-1004-2",
23
+ "description": "Operator-authored `contextRequirements.standardIds` on the FBS overrides the agentic selection",
24
+ "given": "the same project and FBS as AC-1004-1, but with `contextRequirements.standardIds: ['wsd-naming']` authored on the FBS",
25
+ "when": "the bundle assembler runs",
26
+ "then": "the bundle's `context.standardIds` matches the operator-authored value byte-for-byte; the agentic selection is not invoked",
27
+ "testable": true
28
+ },
29
+ {
30
+ "id": "AC-1004-3",
31
+ "description": "The assembler never blocks on an empty selection",
32
+ "given": "a project with no standards registered, or an FBS whose work text matches no standard",
33
+ "when": "the bundle assembler runs",
34
+ "then": "the bundle assembles with `context.standardIds` empty (or absent) and no standards payload; the assembler does not error",
35
+ "testable": true
36
+ },
37
+ {
38
+ "id": "AC-1004-4",
39
+ "description": "Selection is stable per input (deterministic tag-scoring; no wall-clock or random source)",
40
+ "given": "an FBS work text and a standards manifest, both fixed",
41
+ "when": "the selector is invoked twice",
42
+ "then": "the two returned selections are equal (same slugs, same order)",
43
+ "testable": true
44
+ }
45
+ ],
46
+ "createdAt": "2026-08-18T22:54:33.194Z",
47
+ "updatedAt": "2026-08-18T22:54:33.194Z"
48
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "createdAt": "2026-07-20T17:04:24.904Z",
3
3
  "updatedAt": "2026-07-28T16:39:51.461Z",
4
- "description": "The method carries guidance for a periodic and end-of-build fresh-context self-review that drives the app against its acceptance criteria to catch the defect classes green test suites miss, honestly scoped as an in-loop check subordinate to the independent verification gate that rcf finalise runs.",
4
+ "description": "The method carries guidance for a periodic and end-of-build fresh-context self-review that drives the app against its acceptance criteria to catch the defect classes green test suites miss, honestly scoped as an in-loop check subordinate to the independent verification gate that rcf build finalise runs.",
5
5
  "asA": "non-coding product owner",
6
6
  "iWant": "the build to include a periodic fresh look that actually drives the app against what it is supposed to do",
7
7
  "soThat": "defects my passing tests miss are caught before I rely on the result",
@@ -41,7 +41,7 @@
41
41
  "description": "The guidance scopes the self-review honestly as an in-loop check and not the independent gate",
42
42
  "given": "the self-review guidance",
43
43
  "when": "it frames the review",
44
- "then": "it states plainly that this is an in-loop check and not the independent verification gate, names rcf finalise (section 7) as the gate, and says it is guidance and not a new subsystem",
44
+ "then": "it states plainly that this is an in-loop check and not the independent verification gate, names rcf build finalise (section 7) as the gate, and says it is guidance and not a new subsystem",
45
45
  "testable": true
46
46
  }
47
47
  ],
@@ -2,39 +2,39 @@
2
2
  "usId": "US-901",
3
3
  "prdId": "PRD-001",
4
4
  "reqId": "REQ-009",
5
- "title": "Route `rcf verify` through the same dispatcher as the alias bin",
6
- "description": "A consumer running the unified `rcf` CLI can reach the adversarial ship-gate verifier as `rcf verify <verb>` and get identical behaviour to the legacy `rcf-verify <verb>` invocation. The legacy bin remains available and emits a one-line deprecation notice on stderr so scripts can be migrated at their own pace.",
5
+ "title": "Route the verify suite under the `rcf verify` group and delete the legacy alias bin",
6
+ "description": "A consumer running the unified `rcf` CLI reaches the adversarial ship-gate verifier as `rcf verify <verb>` (run | report | provision | cleanup | mcp | browser). The pre-0.10.0 standalone `rcf-verify` bin is deleted outright; the historical 0.7.1 transition-grace alias is gone. The old flat `rcf-verify <verb>` invocation form is not available at all; consumers scripting against it must move to `rcf verify <verb>`.",
7
7
  "asA": "consumer of the RCF Lite CLI",
8
8
  "iWant": "to invoke the verify suite as `rcf verify <verb>` inside the unified CLI",
9
9
  "soThat": "verify sits inside the same verb space as the rest of the RCF chain and I only have to install one package",
10
10
  "acceptanceCriteria": [
11
11
  {
12
12
  "id": "AC-901-1",
13
- "description": "The `rcf` bin exposes `verify` as a top-level subcommand routed to the same dispatcher as the `rcf-verify` alias bin",
13
+ "description": "The `rcf` bin exposes `verify` as a top-level group whose sub-verbs run | report | provision | cleanup | mcp | browser dispatch to the verify-suite handlers",
14
14
  "given": "the unified `rcf` binary imported from bin/rcf.js",
15
- "when": "`verify` is looked up on the top-level SUBCOMMANDS map",
16
- "then": "the handler resolves to the same `main` function that bin/rcf-verify.js exports and dispatches over run/report/provision/cleanup/mcp/help",
15
+ "when": "`verify` is looked up on the top-level GROUPS map",
16
+ "then": "the sub-verb resolves to the corresponding main() from src/verify/cli/{run,report,provision,cleanup,mcp}.js or src/cli/browser-verify.js",
17
17
  "testable": true
18
18
  },
19
19
  {
20
20
  "id": "AC-901-2",
21
- "description": "The top-level `rcf --help` output advertises `verify` as an available subcommand",
21
+ "description": "The top-level `rcf help` output advertises the `verify` group with its sub-verbs",
22
22
  "given": "the unified `rcf` binary",
23
23
  "when": "`--help` or `help` runs",
24
- "then": "the printed usage lists `verify <verb>` alongside the other top-level commands",
24
+ "then": "the printed usage lists the verify group header with the sub-verb catalogue (run, report, provision, cleanup, mcp, browser)",
25
25
  "testable": true
26
26
  },
27
27
  {
28
28
  "id": "AC-901-3",
29
- "description": "The `rcf-verify` alias bin emits a one-line stderr deprecation notice when invoked directly and stays fully functional",
30
- "given": "the `rcf-verify` alias bin invoked as the entry point (isMain)",
31
- "when": "the entry point runs without RCF_QUIET",
32
- "then": "a single line describing the deprecation and the `rcf verify` replacement is written to stderr before dispatch, and the dispatch itself is unchanged",
29
+ "description": "The legacy standalone `rcf-verify` bin is deleted; invoking `rcf-verify <verb>` is not possible from an rcf-lite install, and calling any old flat top-level token via the umbrella `rcf` bin exits 2 with a stderr hint naming the new grouped form",
30
+ "given": "an installed rcf-lite@0.10.0",
31
+ "when": "the operator invokes `rcf <old-flat-verb>` where old-flat-verb is a pre-0.10.0 name (validate, coverage, browser-verify, ...)",
32
+ "then": "package.json:bin carries no `rcf-verify` entry, bin/rcf-verify.js does not exist in the package, and bin/rcf.js writes '[error] usage unknown top-level command <verb>. Try rcf <group> <verb>' to stderr and exits 2 (not a back-compat fallback)",
33
33
  "testable": true
34
34
  }
35
35
  ],
36
- "version": "0.1.0",
36
+ "version": "0.2.0",
37
37
  "status": "draft",
38
38
  "createdAt": "2026-08-11T00:00:00Z",
39
- "updatedAt": "2026-08-11T00:00:00Z"
39
+ "updatedAt": "2026-08-26T00:00:00Z"
40
40
  }
@@ -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
+ }