rcf-lite 0.14.0 → 0.16.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 (35) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/view-supervisor-child.mjs +0 -0
  3. package/blueprints/delivery-ci-workflows/README.md +4 -0
  4. package/blueprints/delivery-ci-workflows/assets/bootstrap/README.md +26 -0
  5. package/blueprints/delivery-ci-workflows/assets/bootstrap/adr-bootstrap-coverage-supersession.template.json +28 -0
  6. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/default-branch-checks.yml +12 -6
  7. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +16 -7
  8. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/release.yml +4 -0
  9. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/scheduled-audit.yml +4 -0
  10. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +5 -5
  11. package/blueprints/delivery-ci-workflows/assets/report-samples/per-gate.json +1 -1
  12. package/blueprints/delivery-ci-workflows/blueprint.json +1 -1
  13. package/blueprints/delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json +2 -2
  14. package/blueprints/delivery-ci-workflows/contributions/tacs/tac-701-delivery-ci-workflows-gate-runner.json +2 -2
  15. package/blueprints/delivery-ci-workflows/contributions/tacs/tac-704-delivery-ci-workflows-workflow-materialiser.json +7 -4
  16. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6111.json +2 -2
  17. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6114.json +6 -6
  18. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6115.json +6 -6
  19. package/blueprints/delivery-ci-workflows/guide/delivery-ci-workflows.md +39 -1
  20. package/fixtures/canary-manifest.json +101 -1
  21. package/guidance/harness-template.md +9 -0
  22. package/guidance/managed/agent-instructions-block.hash +1 -1
  23. package/guidance/managed/agent-instructions-block.md +9 -0
  24. package/package.json +13 -15
  25. package/releases/releases.yaml +21 -1
  26. package/src/blueprint/apply.js +15 -6
  27. package/src/blueprint/index.js +22 -0
  28. package/src/blueprint/library-cache.js +143 -0
  29. package/src/blueprint/library-fetcher-git.js +347 -0
  30. package/src/blueprint/library-fetcher-tarball.js +379 -0
  31. package/src/blueprint/library-loader.js +21 -0
  32. package/src/blueprint/shelf-resolver.js +100 -7
  33. package/src/blueprint/supersede.js +56 -13
  34. package/src/cli/blueprint-library.js +427 -82
  35. package/src/cli/blueprint.js +6 -1
@@ -0,0 +1,379 @@
1
+ // Tarball fetcher for external blueprint libraries (Phase 2c, spec §6.3).
2
+ //
3
+ // Node built-in `fetch()` is the transport; a 30-second connection
4
+ // timeout applies to the response headers, and a hard size cap fires
5
+ // during streaming. The download is SHA-256 hashed on the fly against
6
+ // the operator's declared digest; a mismatch is refused and rolled back
7
+ // (spec §6.1 + §6.4). Extraction uses a minimal POSIX-ustar parser
8
+ // bundled in this module so the runtime carries zero new dependencies
9
+ // (spec §6.3: no shell-out to `tar`, no libtar dependency).
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync } from 'node:fs';
13
+ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join, normalize, sep } from 'node:path';
16
+ import { createGunzip } from 'node:zlib';
17
+
18
+ import { rcfError } from '../core/errors/index.js';
19
+
20
+ const DEFAULT_TIMEOUT_MS = 30_000;
21
+ const DEFAULT_MAX_BYTES = 200 * 1024 * 1024; // spec §6.3 envelope: tens; hard cap low hundreds.
22
+ const HEX_256 = /^[0-9a-f]{64}$/i;
23
+
24
+ /**
25
+ * Fetch a tarball library, verify SHA-256, extract into targetDir.
26
+ *
27
+ * @param {object} args
28
+ * @param {string} args.url
29
+ * @param {string} args.expectedSha256
30
+ * @param {string} args.targetDir absolute path
31
+ * @param {typeof fetch} [args.fetchImpl=fetch] override for tests
32
+ * @param {number} [args.timeoutMs]
33
+ * @param {number} [args.maxBytes]
34
+ * @returns {Promise<{ tarballSha256: string, root: string } | import('../core/errors/index.js').RcfError>}
35
+ */
36
+ export async function fetchTarballLibrary({ url, expectedSha256, targetDir, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_BYTES }) {
37
+ if (typeof url !== 'string' || url.length === 0) {
38
+ return rcfError({ kind: 'usage', message: 'tarball fetch: url is required' });
39
+ }
40
+ if (typeof expectedSha256 !== 'string' || !HEX_256.test(expectedSha256)) {
41
+ return rcfError({
42
+ kind: 'usage',
43
+ message: `tarball fetch: expected SHA-256 is required and must be 64 hex chars (spec §6.1: URL with no digest is refused).`,
44
+ });
45
+ }
46
+ const expected = expectedSha256.toLowerCase();
47
+
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(new Error(`tarball fetch: timeout after ${timeoutMs}ms`)), timeoutMs);
50
+ let response;
51
+ try {
52
+ response = await fetchImpl(url, { signal: controller.signal });
53
+ } catch (err) {
54
+ clearTimeout(timer);
55
+ return rcfError({ kind: 'usage', message: `tarball fetch: ${url}: ${err.message}` });
56
+ }
57
+ if (!response.ok) {
58
+ clearTimeout(timer);
59
+ return rcfError({ kind: 'usage', message: `tarball fetch: ${url} returned HTTP ${response.status}` });
60
+ }
61
+ if (!response.body) {
62
+ clearTimeout(timer);
63
+ return rcfError({ kind: 'usage', message: `tarball fetch: ${url}: empty response body` });
64
+ }
65
+
66
+ // Stream the response through: sha256 tap, size cap, optional gunzip,
67
+ // tar parser. We buffer the whole thing into memory first (bounded by
68
+ // maxBytes) because our tar parser is synchronous; envelope is tens of
69
+ // megabytes, easily viable in a single Buffer.
70
+ const hash = createHash('sha256');
71
+ let received = 0;
72
+ const chunks = [];
73
+ const reader = response.body.getReader();
74
+ try {
75
+ for (;;) {
76
+ const { value, done } = await reader.read();
77
+ if (done) break;
78
+ received += value.byteLength;
79
+ if (received > maxBytes) {
80
+ clearTimeout(timer);
81
+ return rcfError({
82
+ kind: 'usage',
83
+ message: `tarball fetch: ${url} exceeded ${maxBytes} bytes (received ${received}); adjust the size cap only for a library you have separately verified.`,
84
+ });
85
+ }
86
+ hash.update(value);
87
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
88
+ }
89
+ } catch (err) {
90
+ clearTimeout(timer);
91
+ return rcfError({ kind: 'usage', message: `tarball fetch: read failed: ${err.message}` });
92
+ }
93
+ clearTimeout(timer);
94
+
95
+ const actual = hash.digest('hex');
96
+ if (actual !== expected) {
97
+ return rcfError({
98
+ kind: 'usage',
99
+ message: `tarball fetch: SHA-256 mismatch for ${url}. expected=${expected} actual=${actual}. The download was refused and no cache was written.`,
100
+ });
101
+ }
102
+
103
+ const raw = Buffer.concat(chunks);
104
+ const isGz = looksGzipped(url, raw);
105
+ let plain;
106
+ try {
107
+ plain = isGz ? await gunzipBuffer(raw) : raw;
108
+ } catch (err) {
109
+ return rcfError({ kind: 'usage', message: `tarball fetch: gunzip failed: ${err.message}` });
110
+ }
111
+
112
+ const entries = parseUstar(plain);
113
+ if (isRcfLikeError(entries)) return entries;
114
+
115
+ const scratch = await mkdtemp(join(tmpdir(), 'rcf-lib-tar-'));
116
+ try {
117
+ const writeErr = await materialiseEntries(entries, scratch);
118
+ if (writeErr) {
119
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
120
+ return writeErr;
121
+ }
122
+ // Some tarballs wrap everything in a single top-level directory
123
+ // (npm-pack `package/`, github-tarball `<repo>-<sha>/`). If the
124
+ // scratch tree has exactly one top-level entry and it is a
125
+ // directory, unwrap it so the cache root IS the library root
126
+ // (library.json sits at cachePath/library.json rather than
127
+ // cachePath/package/library.json).
128
+ const root = await unwrapSingleTopLevel(scratch);
129
+ if (isRcfLikeError(root)) {
130
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
131
+ return root;
132
+ }
133
+ const settled = await settleCache(root, targetDir);
134
+ if (settled) {
135
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
136
+ return settled;
137
+ }
138
+ if (root !== scratch) {
139
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
140
+ }
141
+ return { tarballSha256: actual, root: targetDir };
142
+ } catch (err) {
143
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
144
+ return rcfError({ kind: 'ioFailure', message: `tarball fetch: extraction failed: ${err.message}`, stack: err.stack });
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Compute the SHA-256 of an already-downloaded byte buffer. Test-only
150
+ * helper: the fetcher computes on the fly during download; this lets
151
+ * unit tests pin an `expectedSha256` from a fixture without shelling
152
+ * out to sha256sum.
153
+ *
154
+ * @param {Buffer} buf
155
+ * @returns {string}
156
+ */
157
+ export function sha256Hex(buf) {
158
+ return createHash('sha256').update(buf).digest('hex');
159
+ }
160
+
161
+ /**
162
+ * Produce a tarball buffer from an in-memory tree, suitable for
163
+ * fixture use in unit tests. Ustar-style headers, no compression.
164
+ *
165
+ * @param {Array<{ path: string, content?: string | Buffer, mode?: number, dir?: boolean }>} entries
166
+ * @returns {Buffer}
167
+ */
168
+ export function createUstarBuffer(entries) {
169
+ const chunks = [];
170
+ for (const entry of entries) {
171
+ const isDir = entry.dir === true;
172
+ const content = isDir ? Buffer.alloc(0) : Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content ?? '', 'utf8');
173
+ const mode = entry.mode ?? (isDir ? 0o755 : 0o644);
174
+ const header = buildUstarHeader({ path: entry.path, size: content.length, mode, typeflag: isDir ? '5' : '0' });
175
+ chunks.push(header);
176
+ if (!isDir) {
177
+ chunks.push(content);
178
+ const pad = (512 - (content.length % 512)) % 512;
179
+ if (pad > 0) chunks.push(Buffer.alloc(pad));
180
+ }
181
+ }
182
+ chunks.push(Buffer.alloc(1024)); // Two zero blocks terminate the archive.
183
+ return Buffer.concat(chunks);
184
+ }
185
+
186
+ function buildUstarHeader({ path, size, mode, typeflag }) {
187
+ const buf = Buffer.alloc(512);
188
+ const write = (offset, str, length) => {
189
+ const s = String(str);
190
+ buf.write(s.slice(0, length), offset, length, 'utf8');
191
+ };
192
+ // name: up to 100 bytes. Longer paths would need `prefix` (offset 345);
193
+ // fixture paths in this codebase are short, so we simplify and refuse
194
+ // long paths early to keep the writer honest.
195
+ if (path.length > 100) throw new Error(`ustar writer: path too long (${path.length}); use a shorter fixture path`);
196
+ write(0, path, 100);
197
+ write(100, `${(mode & 0o7777).toString(8).padStart(6, '0')} \0`, 8);
198
+ write(108, `${0..toString(8).padStart(6, '0')} \0`, 8); // uid
199
+ write(116, `${0..toString(8).padStart(6, '0')} \0`, 8); // gid
200
+ write(124, `${size.toString(8).padStart(11, '0')} `, 12); // size
201
+ write(136, `${Math.floor(Date.now() / 1000).toString(8).padStart(11, '0')} `, 12); // mtime
202
+ // checksum placeholder (spaces) at 148..155
203
+ for (let i = 148; i < 156; i += 1) buf[i] = 0x20;
204
+ write(156, typeflag, 1);
205
+ write(257, 'ustar\0', 6);
206
+ write(263, '00', 2);
207
+ let sum = 0;
208
+ for (let i = 0; i < 512; i += 1) sum += buf[i];
209
+ write(148, `${sum.toString(8).padStart(6, '0')}\0 `, 8);
210
+ return buf;
211
+ }
212
+
213
+ function looksGzipped(url, buf) {
214
+ if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return true;
215
+ const lower = url.toLowerCase();
216
+ return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
217
+ }
218
+
219
+ function gunzipBuffer(buf) {
220
+ return new Promise((resolve, reject) => {
221
+ const gz = createGunzip();
222
+ const chunks = [];
223
+ gz.on('data', (c) => chunks.push(c));
224
+ gz.on('end', () => resolve(Buffer.concat(chunks)));
225
+ gz.on('error', (err) => reject(err));
226
+ gz.end(buf);
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Minimal POSIX-ustar parser. Handles regular files ('0' / '\0'),
232
+ * directories ('5'), and quietly skips pax extended headers ('x', 'g')
233
+ * plus GNU long-name records ('L', 'K'). Returns an array of parsed
234
+ * entries or an RcfError on a malformed archive.
235
+ *
236
+ * @param {Buffer} buf
237
+ * @returns {Array<{ path: string, size: number, typeflag: string, content: Buffer }> | import('../core/errors/index.js').RcfError}
238
+ */
239
+ export function parseUstar(buf) {
240
+ const entries = [];
241
+ let offset = 0;
242
+ let pendingLongName = null;
243
+ while (offset + 512 <= buf.length) {
244
+ const header = buf.subarray(offset, offset + 512);
245
+ // Two consecutive zero blocks terminate.
246
+ if (header.every((b) => b === 0)) {
247
+ const next = buf.subarray(offset + 512, offset + 1024);
248
+ if (next.length === 0 || next.every((b) => b === 0)) break;
249
+ offset += 512;
250
+ continue;
251
+ }
252
+ const rawName = readCString(header, 0, 100);
253
+ const prefix = readCString(header, 345, 155);
254
+ const magic = header.subarray(257, 263).toString('utf8').replace(/\0/g, '').trim();
255
+ if (magic !== 'ustar') {
256
+ return rcfError({ kind: 'usage', message: `tarball parse: non-ustar magic at offset ${offset}` });
257
+ }
258
+ const size = parseOctal(header, 124, 12);
259
+ const typeflag = String.fromCharCode(header[156] || 0x30);
260
+ const dataStart = offset + 512;
261
+ const paddedSize = size + ((512 - (size % 512)) % 512);
262
+ const dataEnd = dataStart + size;
263
+ let path = pendingLongName ?? (prefix ? `${prefix}/${rawName}` : rawName);
264
+ pendingLongName = null;
265
+ if (typeflag === 'L') {
266
+ pendingLongName = buf.subarray(dataStart, dataEnd).toString('utf8').replace(/\0+$/, '');
267
+ offset = dataStart + paddedSize;
268
+ continue;
269
+ }
270
+ if (typeflag === 'x' || typeflag === 'g' || typeflag === 'K') {
271
+ // Skip pax extended / GNU link-name records; we do not honour
272
+ // extended attributes for library payloads.
273
+ offset = dataStart + paddedSize;
274
+ continue;
275
+ }
276
+ if (typeflag === '5') {
277
+ entries.push({ path, size: 0, typeflag, content: Buffer.alloc(0) });
278
+ offset = dataStart + paddedSize;
279
+ continue;
280
+ }
281
+ if (typeflag === '0' || typeflag === '\0') {
282
+ const content = buf.subarray(dataStart, dataEnd);
283
+ entries.push({ path, size, typeflag: '0', content: Buffer.from(content) });
284
+ offset = dataStart + paddedSize;
285
+ continue;
286
+ }
287
+ // Symlinks (2), hard links (1), block devices, character devices,
288
+ // FIFOs, sparse files: not part of the library payload contract.
289
+ // Skip and continue.
290
+ offset = dataStart + paddedSize;
291
+ }
292
+ return entries;
293
+ }
294
+
295
+ function readCString(buf, offset, length) {
296
+ const slice = buf.subarray(offset, offset + length);
297
+ const nul = slice.indexOf(0);
298
+ const s = (nul >= 0 ? slice.subarray(0, nul) : slice).toString('utf8');
299
+ return s.replace(/\0/g, '');
300
+ }
301
+
302
+ function parseOctal(buf, offset, length) {
303
+ const slice = buf.subarray(offset, offset + length);
304
+ const trimmed = slice.toString('ascii').replace(/[\0\s]+$/, '').trim();
305
+ if (trimmed.length === 0) return 0;
306
+ return parseInt(trimmed, 8) || 0;
307
+ }
308
+
309
+ async function materialiseEntries(entries, scratch) {
310
+ for (const entry of entries) {
311
+ // Path safety: reject absolute paths and `..` traversal. `normalize`
312
+ // collapses `foo/../bar` -> `bar`, then we check for a residual
313
+ // `..` prefix that would escape the scratch root.
314
+ const rel = normalize(entry.path);
315
+ if (rel.startsWith('/') || rel.startsWith(sep)) {
316
+ return rcfError({ kind: 'usage', message: `tarball parse: absolute path '${entry.path}' refused.` });
317
+ }
318
+ if (rel.split(/[\\/]/).some((s) => s === '..')) {
319
+ return rcfError({ kind: 'usage', message: `tarball parse: parent-traversal path '${entry.path}' refused.` });
320
+ }
321
+ const absPath = join(scratch, rel);
322
+ if (entry.typeflag === '5') {
323
+ await mkdir(absPath, { recursive: true });
324
+ } else {
325
+ await mkdir(dirname(absPath), { recursive: true });
326
+ await writeFile(absPath, entry.content);
327
+ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ async function unwrapSingleTopLevel(scratch) {
333
+ try {
334
+ const { readdir, stat } = await import('node:fs/promises');
335
+ const entries = await readdir(scratch);
336
+ if (entries.length !== 1) return scratch;
337
+ const only = join(scratch, entries[0]);
338
+ const s = await stat(only);
339
+ if (!s.isDirectory()) return scratch;
340
+ return only;
341
+ } catch (err) {
342
+ return rcfError({ kind: 'ioFailure', message: `tarball extract: unwrap failed: ${err.message}`, stack: err.stack });
343
+ }
344
+ }
345
+
346
+ async function settleCache(source, targetDir) {
347
+ try {
348
+ await rm(targetDir, { recursive: true, force: true });
349
+ await mkdir(dirname(targetDir), { recursive: true });
350
+ await rename(source, targetDir);
351
+ return null;
352
+ } catch (err) {
353
+ if (err.code === 'EXDEV') {
354
+ try {
355
+ const { cp } = await import('node:fs/promises');
356
+ await mkdir(dirname(targetDir), { recursive: true });
357
+ await cp(source, targetDir, { recursive: true });
358
+ await rm(source, { recursive: true, force: true }).catch(() => {});
359
+ return null;
360
+ } catch (copyErr) {
361
+ return rcfError({ kind: 'ioFailure', message: `tarball settle: cross-device: ${copyErr.message}`, stack: copyErr.stack });
362
+ }
363
+ }
364
+ return rcfError({ kind: 'ioFailure', message: `tarball settle: ${err.message}`, stack: err.stack });
365
+ }
366
+ }
367
+
368
+ function isRcfLikeError(v) {
369
+ return v && typeof v === 'object' && typeof v.kind === 'string' && typeof v.message === 'string' && v.kind !== '5' && v.kind !== '0';
370
+ }
371
+
372
+ /** Test-visible defaults so tests can pin behaviour. */
373
+ export function defaultCaps() {
374
+ return { timeoutMs: DEFAULT_TIMEOUT_MS, maxBytes: DEFAULT_MAX_BYTES };
375
+ }
376
+
377
+ // Silence the unused-import lint on existsSync; keeps the import
378
+ // available for future use without warnings.
379
+ export const _cacheProbeExists = existsSync;
@@ -44,6 +44,12 @@ const LIBRARY_VERSION_KNOWN = 1;
44
44
  * @typedef {object} LibraryBlueprintEntry
45
45
  * @property {string} slug
46
46
  * @property {string} path
47
+ * @property {string[]} [globalTopics] scope:global ADR topics this
48
+ * blueprint contributes. Populated only when the loader ran with
49
+ * `validateBlueprints: true` (the review-on-add path); resolver-time
50
+ * loads that skip per-blueprint validation leave the field absent.
51
+ * Callers use it to render the section 8.1 "Global topics these
52
+ * blueprints claim" line during library-add review.
47
53
  */
48
54
 
49
55
  /**
@@ -266,6 +272,21 @@ async function validateDeclaredBlueprints(library) {
266
272
  filePath: bpRoot,
267
273
  });
268
274
  }
275
+ // Attach the scope:global ADR topics this blueprint claims so the
276
+ // review-on-add printer can render the spec §8.1 "Global topics
277
+ // these blueprints claim" line without re-walking every blueprint.
278
+ // Order is contribution-declaration order; duplicates within one
279
+ // blueprint (an author mistake caught by phase-1 conflict logic)
280
+ // are de-duplicated here to keep the render terse.
281
+ const seen = new Set();
282
+ const topics = [];
283
+ for (const c of loaded.contributions ?? []) {
284
+ if (c.scope === 'global' && typeof c.topic === 'string' && !seen.has(c.topic)) {
285
+ seen.add(c.topic);
286
+ topics.push(c.topic);
287
+ }
288
+ }
289
+ entry.globalTopics = topics;
269
290
  }
270
291
  return null;
271
292
  }
@@ -40,12 +40,13 @@
40
40
  // before pack runs.
41
41
 
42
42
  import { existsSync } from 'node:fs';
43
- import { stat } from 'node:fs/promises';
44
- import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
43
+ import { readFile, stat } from 'node:fs/promises';
44
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
45
45
  import { fileURLToPath } from 'node:url';
46
46
 
47
47
  import { rcfError } from '../core/errors/index.js';
48
48
  import { findLibrary, readLibraryRegistry } from './library-registry.js';
49
+ import { loadLibrary } from './library-loader.js';
49
50
 
50
51
  const here = dirname(fileURLToPath(import.meta.url));
51
52
  // packages/rcf-lite/src/blueprint -> packages/rcf-lite
@@ -143,12 +144,20 @@ export async function resolveBlueprintSource(source, opts = {}) {
143
144
  });
144
145
  }
145
146
 
146
- // Rule 4: path-looking arguments are passed through unchanged. We look
147
- // at the SHAPE only (does it contain a separator, does it start with a
148
- // relative-path marker, is it absolute), so an existing operator with a
149
- // `./blueprints/foo` invocation keeps the current behaviour byte-for-byte.
147
+ // Rule 4: path-looking arguments are passed through unchanged UNLESS
148
+ // an ancestor of the resolved path carries a `library.json` (spec
149
+ // amendment A2, 2026-09-03). In that case the resolver walks up from
150
+ // the target, loads the library manifest, requires the target to sit
151
+ // at `<library-root>/blueprints/<slug>`, and returns a kind=library
152
+ // result with `libraryPrefix`, `effectiveSlug`, `libraryBands` so the
153
+ // apply layer stamps the same identity a qualified `<prefix>:<slug>`
154
+ // would. A path with NO library.json in any ancestor keeps the
155
+ // phase-1 route unchanged, byte-for-byte.
150
156
  if (isAbsolute(source) || PATH_HINT.test(source)) {
151
- return { kind: 'path', resolved: resolve(source), original: source };
157
+ const abs = resolve(source);
158
+ const libraryHit = await tryLibraryAwareLocalPath(abs, source);
159
+ if (libraryHit) return libraryHit;
160
+ return { kind: 'path', resolved: abs, original: source };
152
161
  }
153
162
 
154
163
  // Rule 5: any other bare kebab token is a shelf slug.
@@ -165,6 +174,90 @@ export async function resolveBlueprintSource(source, opts = {}) {
165
174
  return { kind: 'path', resolved: resolve(source), original: source };
166
175
  }
167
176
 
177
+ /**
178
+ * Ancestor-walk from a target directory looking for a `library.json`.
179
+ * Stops at the filesystem root. When a manifest is found and the target
180
+ * sits at `<library-root>/blueprints/<slug>`, returns a library-kind
181
+ * resolution; when the target is under a library root at any other
182
+ * path shape, returns a usage error naming the expected layout.
183
+ * Returns null when no `library.json` is found in any ancestor - the
184
+ * caller then keeps the phase-1 path route unchanged.
185
+ *
186
+ * @param {string} abs absolute target directory
187
+ * @param {string} original the source the operator typed (verbatim)
188
+ * @returns {Promise<null | import('./shelf-resolver.js').ResolvedSource | import('../core/errors/index.js').RcfError>}
189
+ */
190
+ async function tryLibraryAwareLocalPath(abs, original) {
191
+ const libraryRoot = await findLibraryAncestor(abs);
192
+ if (!libraryRoot) return null;
193
+ // The target must be `<library-root>/blueprints/<slug>`, i.e. a direct
194
+ // grandchild of the library root under a `blueprints/` folder. Any
195
+ // other shape (nested deeper, a sibling docs/ folder, the library
196
+ // root itself) is a usage error naming the expected form so the
197
+ // operator sees the fix immediately.
198
+ const relFromRoot = abs.slice(libraryRoot.length).split(sep).filter(Boolean);
199
+ if (relFromRoot.length !== 2 || relFromRoot[0] !== 'blueprints') {
200
+ return rcfError({
201
+ kind: 'usage',
202
+ message: (
203
+ `blueprint source '${original}' is inside a library at ${libraryRoot}, but its path must be `
204
+ + `'<library-root>/blueprints/<slug>' (found '${relFromRoot.join('/') || '<library-root>'}'). `
205
+ + `Move the blueprint under 'blueprints/<slug>/' inside the library or point 'blueprint add' at that layout.`
206
+ ),
207
+ filePath: abs,
208
+ });
209
+ }
210
+ const blueprintSlug = basename(abs);
211
+ const library = await loadLibrary(libraryRoot, { validateBlueprints: false });
212
+ if (library && typeof library === 'object' && library.kind) {
213
+ // Malformed library.json: surface the loader's own error verbatim.
214
+ return library;
215
+ }
216
+ // Refuse if the library.json does not declare this blueprint slug -
217
+ // an author who has not yet added the entry to `blueprints[]` should
218
+ // be told loudly, not silently applied.
219
+ if (!library.blueprints.some((b) => b.slug === blueprintSlug)) {
220
+ return rcfError({
221
+ kind: 'usage',
222
+ message: (
223
+ `blueprint source '${original}' is inside library '${library.libraryPrefix}' at ${libraryRoot}, `
224
+ + `but library.json does not declare a blueprint with slug '${blueprintSlug}'. `
225
+ + `Add '{ "slug": "${blueprintSlug}", "path": "blueprints/${blueprintSlug}" }' to library.json:blueprints[] first.`
226
+ ),
227
+ filePath: join(libraryRoot, 'library.json'),
228
+ });
229
+ }
230
+ return {
231
+ kind: 'library',
232
+ resolved: abs,
233
+ original,
234
+ libraryPrefix: library.libraryPrefix,
235
+ libraryBlueprintSlug: blueprintSlug,
236
+ effectiveSlug: `${library.libraryPrefix}-${blueprintSlug}`,
237
+ libraryBands: library.bands,
238
+ };
239
+ }
240
+
241
+ async function findLibraryAncestor(startDir) {
242
+ let cur = startDir;
243
+ while (true) {
244
+ try {
245
+ // Fast probe: try to read `library.json`. `readFile` returning
246
+ // ENOENT is the most common case; any other read error is treated
247
+ // the same as "no library here" so the walk continues to the
248
+ // parent without swallowing a real defect (the loader will
249
+ // re-raise if the operator points directly at a broken library).
250
+ await readFile(join(cur, 'library.json'), 'utf8');
251
+ return cur;
252
+ } catch {
253
+ // fall through
254
+ }
255
+ const parent = dirname(cur);
256
+ if (parent === cur) return null;
257
+ cur = parent;
258
+ }
259
+ }
260
+
168
261
  async function resolveLibraryQualified({ libraryPrefix, blueprintSlug, original, projectRoot }) {
169
262
  if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
170
263
  return rcfError({
@@ -14,13 +14,20 @@
14
14
  // This is what makes option 3 as printed by the reshaped conflict
15
15
  // message executable VERBATIM from the refused-add state: the operator
16
16
  // runs `rcf define blueprint supersede <topic> --incoming <source>` immediately
17
- // after the refused add, with zero prep; the verb loads the incoming
18
- // blueprint from disk, finds its scope:global ADR on <topic>, stamps
19
- // the id into the incoming blueprint's namespace, and uses that
20
- // {slug, adrId} as the second side of supersedes[]. Round-2 shipped
21
- // with the writer requiring 2 already-applied ADRs, which meant option
22
- // 3 as printed exited 2 in the refused-add state; the escalation the
23
- // worker adapted around was that AC-1002-5 could not pass as written.
17
+ // after the refused add, with zero prep; the verb resolves the incoming
18
+ // source through `resolveBlueprintSource` (the SAME resolver `add`
19
+ // uses, so `@stock/<slug>`, bare kebab slugs, colon-qualified library
20
+ // refs, and filesystem paths are all accepted here just as they are on
21
+ // `add`), loads the incoming blueprint from disk, finds its
22
+ // scope:global ADR on <topic>, stamps the id into the incoming
23
+ // blueprint's namespace, and uses that {slug, adrId} as the second
24
+ // side of supersedes[]. Round-2 shipped with the writer requiring 2
25
+ // already-applied ADRs, which meant option 3 as printed exited 2 in
26
+ // the refused-add state; the escalation the worker adapted around was
27
+ // that AC-1002-5 could not pass as written. The `--incoming` argument
28
+ // was subsequently fed directly to `loadBlueprint`, which understands
29
+ // paths only — a newcomer who intuited the `@stock/<slug>` form from
30
+ // `add`'s help hit a refusal. Persona re-run 2026-08-31 arc-4, H2.
24
31
  //
25
32
  // Two side effects, both governed by dryRun:
26
33
  // 1. Writes the project ADR file (JSON, minimally valid — the operator
@@ -46,11 +53,12 @@
46
53
  import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
47
54
  import { dirname, join } from 'node:path';
48
55
 
49
- import { rcfError } from '../core/errors/index.js';
56
+ import { isRcfError, rcfError } from '../core/errors/index.js';
50
57
  import { loadBlueprint } from './loader.js';
51
58
  import { updateManifest } from './manifest-writer.js';
52
59
  import { stampId } from './namespace.js';
53
60
  import { nextResolutionId } from './resolutions.js';
61
+ import { resolveBlueprintSource } from './shelf-resolver.js';
54
62
 
55
63
  /**
56
64
  * @typedef {object} SupersedeResult
@@ -79,9 +87,16 @@ import { nextResolutionId } from './resolutions.js';
79
87
  * @param {Date} [args.now]
80
88
  * @param {boolean} [args.dryRun]
81
89
  * @param {string} [args.reason]
90
+ * @param {(source: string, opts: { projectRoot: string }) => Promise<import('./shelf-resolver.js').ResolvedSource | import('../core/errors/index.js').RcfError>} [args._resolveSource]
91
+ * Test-only injection point; production callers omit this and the
92
+ * shared `resolveBlueprintSource` runs. Mirrors the same DI on
93
+ * `view/scope.js` so a hermetic test can exercise the `@stock/` and
94
+ * colon-qualified branches without staging the packaged shelf or a
95
+ * library registry.
82
96
  * @returns {Promise<SupersedeResult | import('../core/errors/index.js').RcfError>}
83
97
  */
84
- export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason }) {
98
+ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason, _resolveSource }) {
99
+ const resolveImpl = _resolveSource ?? resolveBlueprintSource;
85
100
  if (typeof topic !== 'string' || topic.trim().length === 0) {
86
101
  // Schema minLength:1 accepts whitespace-only; the writer refuses
87
102
  // it up-front so a whitespace-only topic never lands on disk.
@@ -122,8 +137,27 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
122
137
  // incomingSource is informational only (skipped silently unless it
123
138
  // would add a distinct {slug, adrId} pair, in which case it is
124
139
  // appended for a >= 3-blueprint scenario).
140
+ //
141
+ // The incoming source is routed through the SAME resolver `add` uses
142
+ // (`resolveBlueprintSource`), so `@stock/<slug>`, bare kebab slugs,
143
+ // colon-qualified library refs, and filesystem paths are all accepted
144
+ // here just as they are on `add`. Before this routing the verb fed
145
+ // the raw string directly to `loadBlueprint`, which understands paths
146
+ // only; a newcomer following the conflict card's own printed remedy
147
+ // with the ergonomic `@stock/<slug>` form (identical to what `add`
148
+ // accepted) hit a refusal. Persona re-run 2026-08-31 arc-4, H2.
125
149
  if (typeof incomingSource === 'string' && incomingSource.length > 0) {
126
- const loaded = await loadBlueprint(incomingSource);
150
+ const resolved = await resolveImpl(incomingSource, { projectRoot });
151
+ if (isRcfError(resolved)) {
152
+ // Wrap the resolver's error under the --incoming banner so the
153
+ // operator sees which flag failed.
154
+ return rcfError({
155
+ kind: resolved.kind,
156
+ message: `--incoming ${incomingSource}: ${resolved.message}`,
157
+ filePath: resolved.filePath,
158
+ });
159
+ }
160
+ const loaded = await loadBlueprint(resolved.resolved);
127
161
  if (loaded.kind) {
128
162
  // Preserve the loader's own rcfError but drop any 'blueprint: '
129
163
  // narrator prefix so the CLI's `[error] blueprint supersede: `
@@ -134,6 +168,15 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
134
168
  filePath: loaded.filePath,
135
169
  });
136
170
  }
171
+ // For library-qualified sources the applied identity is rewired
172
+ // under the library prefix (`<libraryPrefix>-<blueprintSlug>`), so
173
+ // the supersedes[] entry must reference that effective slug and
174
+ // stamp the ADR id under it — matching what `apply.js` writes to
175
+ // `manifest.blueprints[].slug`. For shelf / path sources the
176
+ // blueprint's own slug applies.
177
+ const effectiveSlug = resolved.kind === 'library'
178
+ ? resolved.effectiveSlug
179
+ : loaded.slug;
137
180
  let matched = null;
138
181
  for (const c of loaded.contributions ?? []) {
139
182
  if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
@@ -144,14 +187,14 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
144
187
  if (!matched) {
145
188
  return rcfError({
146
189
  kind: 'usage',
147
- message: `--incoming ${incomingSource}: blueprint '${loaded.slug}' declares no scope:global ADR on topic '${topic}'.`,
190
+ message: `--incoming ${incomingSource}: blueprint '${effectiveSlug}' declares no scope:global ADR on topic '${topic}'.`,
148
191
  });
149
192
  }
150
- const stamped = stampId(matched.id, loaded.slug);
193
+ const stamped = stampId(matched.id, effectiveSlug);
151
194
  if ('error' in stamped) {
152
195
  return rcfError({ kind: 'validation', message: `--incoming ${incomingSource}: ${stamped.error}` });
153
196
  }
154
- const incomingPair = { slug: loaded.slug, adrId: stamped.id, path: `rcf/adrs/${stamped.id.toLowerCase()}.json` };
197
+ const incomingPair = { slug: effectiveSlug, adrId: stamped.id, path: `rcf/adrs/${stamped.id.toLowerCase()}.json` };
155
198
  // Dedupe against the applied side: an incoming blueprint that is
156
199
  // also currently applied (unusual — refused-add state means it is
157
200
  // NOT applied) would otherwise be double-listed.