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,318 @@
1
+ // `rcf blueprint supersede <topic> [--incoming <source>]` implementation.
2
+ //
3
+ // Scaffolds a project-level ADR at `rcf/adrs/adr-NNN-<kebab-topic>.json`
4
+ // that supersedes the CONFLICT PAIR on the topic (one currently-applied
5
+ // blueprint's scope:global ADR + one incoming (would-be-applied)
6
+ // blueprint's scope:global ADR — Baz ruling, w-2026-08-19-008 round 3),
7
+ // and appends a matching `manifest.resolutions[]` record so the conflict
8
+ // detector honours the resolution when the operator re-runs
9
+ // `rcf blueprint add <incoming source>`.
10
+ //
11
+ // The `incomingSource` argument (mapped to `--incoming <source>` on the
12
+ // CLI) is REQUIRED when the applied-blueprints count on the topic is
13
+ // < 2, and silently accepted (informational) when it is already >= 2.
14
+ // This is what makes option 3 as printed by the reshaped conflict
15
+ // message executable VERBATIM from the refused-add state: the operator
16
+ // runs `rcf 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.
24
+ //
25
+ // Two side effects, both governed by dryRun:
26
+ // 1. Writes the project ADR file (JSON, minimally valid — the operator
27
+ // is expected to fill out the context / decision / consequences
28
+ // body; the file lands with prefilled stubs that are valid against
29
+ // adr.schema.json so no adr-shaped tool errors on the freshly
30
+ // scaffolded file).
31
+ // 2. Appends `manifest.resolutions[]` via the shared manifest writer.
32
+ //
33
+ // The two writes are ordered: ADR first, manifest second. If the ADR
34
+ // write succeeds and the manifest write fails, the operator is left
35
+ // with a well-formed project ADR they can either re-run supersede
36
+ // against (idempotent by resolvedByAdrId) or hand-clean; the reverse
37
+ // order would leave a manifest resolution pointing at a non-existent
38
+ // ADR, which is the worse failure mode.
39
+ //
40
+ // Error-message prefix discipline (round-3 mechanical P1):
41
+ // every rcfError returned from this module is PREFIX-FREE — no
42
+ // `blueprint supersede: ` in the message body. The CLI edge prepends
43
+ // `[error] blueprint supersede: ` on every rcfError from the supersede
44
+ // verb, and doubling the prefix reads badly on the terminal.
45
+
46
+ import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
47
+ import { dirname, join } from 'node:path';
48
+
49
+ import { rcfError } from '../core/errors/index.js';
50
+ import { loadBlueprint } from './loader.js';
51
+ import { updateManifest } from './manifest-writer.js';
52
+ import { stampId } from './namespace.js';
53
+ import { nextResolutionId } from './resolutions.js';
54
+
55
+ /**
56
+ * @typedef {object} SupersedeResult
57
+ * @property {boolean} superseded
58
+ * @property {string} topic
59
+ * @property {string} resolvedByAdrId
60
+ * @property {string} resolvedByAdrPath
61
+ * @property {Array<{ slug: string, adrId: string, path: string }>} supersedes
62
+ * @property {string} resolutionId
63
+ */
64
+
65
+ /**
66
+ * @param {object} args
67
+ * @param {string} args.projectRoot
68
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
69
+ * @param {string} args.topic
70
+ * @param {string} [args.incomingSource]
71
+ * Path to a blueprint source directory (a `blueprint.json` + a
72
+ * `contributions/` tree). Required when the applied-blueprints count
73
+ * on `topic` is < 2 — the verb loads the blueprint from disk, finds
74
+ * its scope:global ADR on `topic`, stamps its id into the incoming
75
+ * blueprint's namespace, and uses that {slug, adrId} as the second
76
+ * side of `supersedes[]`. Silently accepted (informational, does not
77
+ * contribute a second entry beyond dedupe) when >= 2 applied ADRs on
78
+ * the topic already exist.
79
+ * @param {Date} [args.now]
80
+ * @param {boolean} [args.dryRun]
81
+ * @param {string} [args.reason]
82
+ * @returns {Promise<SupersedeResult | import('../core/errors/index.js').RcfError>}
83
+ */
84
+ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason }) {
85
+ if (typeof topic !== 'string' || topic.trim().length === 0) {
86
+ // Schema minLength:1 accepts whitespace-only; the writer refuses
87
+ // it up-front so a whitespace-only topic never lands on disk.
88
+ return rcfError({ kind: 'usage', message: `topic is required (e.g. rcf blueprint supersede errorEnvelope --incoming ./blueprints/rest)` });
89
+ }
90
+ // Topic is a LOOKUP KEY into applied ADR topics, not a slug (ADR-010).
91
+ // Schema is minLength:1 with no character constraint and the schema
92
+ // docs explicitly cite camelCase examples ('errorEnvelope'). The
93
+ // shipped SPA + REST blueprints declare camelCase topics; we accept
94
+ // any topic string that exact-matches an applied or incoming ADR
95
+ // topic below, and kebab-ify only when projecting into the
96
+ // scaffolded project-ADR id slug tail (adrId grammar requires
97
+ // lowercase kebab after the digits).
98
+ if (reason !== undefined && typeof reason === 'string' && reason.length > 0 && reason.trim().length === 0) {
99
+ // Non-empty whitespace-only reason: refuse before write. An
100
+ // undefined / empty-string reason is fine (the field is optional
101
+ // on the schema; empty-string simply becomes 'omit').
102
+ return rcfError({ kind: 'usage', message: `--reason must not be whitespace-only.` });
103
+ }
104
+
105
+ const applied = Array.isArray(tree.manifest?.blueprints) ? tree.manifest.blueprints : [];
106
+ const supersedes = [];
107
+ for (const bp of applied) {
108
+ for (const c of bp.contributions ?? []) {
109
+ if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
110
+ supersedes.push({ slug: bp.slug, adrId: c.id, path: c.path });
111
+ }
112
+ }
113
+ }
114
+
115
+ // Baz ruling (round 3): the supersession record's two sides are the
116
+ // CONFLICT PAIR — one applied + one incoming — and this verb must
117
+ // accept the incoming blueprint as the second side. `--incoming
118
+ // <source>` loads the incoming blueprint from disk, finds its
119
+ // scope:global ADR on the topic, and adds it to supersedes[]. When
120
+ // supersedes[] already has >= 2 entries from applied blueprints, an
121
+ // incomingSource is informational only (skipped silently unless it
122
+ // would add a distinct {slug, adrId} pair, in which case it is
123
+ // appended for a >= 3-blueprint scenario).
124
+ if (typeof incomingSource === 'string' && incomingSource.length > 0) {
125
+ const loaded = await loadBlueprint(incomingSource);
126
+ if (loaded.kind) {
127
+ // Preserve the loader's own rcfError but drop any 'blueprint: '
128
+ // narrator prefix so the CLI's `[error] blueprint supersede: `
129
+ // reads clean.
130
+ return rcfError({
131
+ kind: loaded.kind === 'usage' ? 'usage' : loaded.kind,
132
+ message: `--incoming ${incomingSource}: ${loaded.message.replace(/^blueprint(?:\s+add|\s+supersede)?:\s*/, '')}`,
133
+ filePath: loaded.filePath,
134
+ });
135
+ }
136
+ let matched = null;
137
+ for (const c of loaded.contributions ?? []) {
138
+ if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
139
+ matched = c;
140
+ break;
141
+ }
142
+ }
143
+ if (!matched) {
144
+ return rcfError({
145
+ kind: 'usage',
146
+ message: `--incoming ${incomingSource}: blueprint '${loaded.slug}' declares no scope:global ADR on topic '${topic}'.`,
147
+ });
148
+ }
149
+ const stamped = stampId(matched.id, loaded.slug);
150
+ if ('error' in stamped) {
151
+ return rcfError({ kind: 'validation', message: `--incoming ${incomingSource}: ${stamped.error}` });
152
+ }
153
+ const incomingPair = { slug: loaded.slug, adrId: stamped.id, path: `rcf/adrs/${stamped.id.toLowerCase()}.json` };
154
+ // Dedupe against the applied side: an incoming blueprint that is
155
+ // also currently applied (unusual — refused-add state means it is
156
+ // NOT applied) would otherwise be double-listed.
157
+ const alreadyListed = supersedes.some((s) => s.slug === incomingPair.slug && s.adrId === incomingPair.adrId);
158
+ if (!alreadyListed) supersedes.push(incomingPair);
159
+ }
160
+
161
+ if (supersedes.length < 2) {
162
+ return rcfError({
163
+ kind: 'usage',
164
+ message: `topic '${topic}' has ${supersedes.length} scope:global ADR(s) across applied+incoming; at least two are required for a supersession record. If the incoming blueprint has not been named, add \`--incoming <source>\` (the same source you passed to \`rcf blueprint add\`).`,
165
+ });
166
+ }
167
+
168
+ // Mint the project ADR id: numeric-only in the ADR-NNN-<topic> shape
169
+ // (topic slug appended as the ADR grammar's optional suffix). Read
170
+ // every ADR the walker already loaded and pick max(NNN) + 1 across
171
+ // all ADR ids regardless of blueprint namespace. NNN is zero-padded
172
+ // to three digits (adrId pattern requires \d{3,}).
173
+ const projectAdrId = mintProjectAdrId(tree, topic);
174
+ const projectAdrPath = `rcf/adrs/${projectAdrId.toLowerCase()}.json`;
175
+ const absAdrPath = join(projectRoot, projectAdrPath);
176
+
177
+ // Compose the project ADR body. Required fields on adr.schema.json:
178
+ // adrId, prdId, tadId, version, status, title, context, decision,
179
+ // consequences, createdAt, updatedAt. The prdId + tadId come from the
180
+ // tree's roots; the rest carry prefilled operator-editable stubs
181
+ // (minLength 1 satisfied, no <PLACEHOLDER> tokens the tree would
182
+ // choke on later).
183
+ const prdId = tree.manifest?.prd?.id;
184
+ const tadId = tree.manifest?.tad?.id;
185
+ if (typeof prdId !== 'string' || typeof tadId !== 'string') {
186
+ return rcfError({ kind: 'usage', message: `manifest is missing prd or tad; cannot scaffold a project ADR without prdId / tadId.` });
187
+ }
188
+
189
+ const isoNow = now.toISOString();
190
+ const relatedAdrs = supersedes.map((s) => s.adrId).filter((id) => /^ADR-\d{3,}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/.test(id));
191
+ const superseededSlugs = supersedes.map((s) => `${s.adrId} (blueprint ${s.slug})`).join(', ');
192
+ const adrBody = {
193
+ adrId: projectAdrId,
194
+ prdId,
195
+ tadId,
196
+ version: '1.0.0',
197
+ status: 'accepted',
198
+ title: `Project ruling on ${topic} (supersedes ${supersedes.length} blueprint ADR${supersedes.length === 1 ? '' : 's'})`,
199
+ context: `Two or more applied blueprints each contributed a scope:global ADR on the '${topic}' topic: ${superseededSlugs}. Composition of these blueprints on one project needs a single project-level decision on ${topic}; the blueprint ADRs are retained on disk as superseded history.`,
200
+ decision: `Adopt a project-level ruling on ${topic}. This ADR is the live decision; the blueprint ADRs listed under relatedAdrs are superseded and their content stands as historical context only.`,
201
+ consequences: `The blueprint conflict on topic '${topic}' is honoured via manifest.resolutions[]. Any future blueprint added with a scope:global ADR on '${topic}' must be listed on the resolution (or a fresh resolution must be recorded) before rcf blueprint add proceeds.`,
202
+ ...(relatedAdrs.length > 0 ? { relatedAdrs } : {}),
203
+ createdAt: isoNow,
204
+ updatedAt: isoNow,
205
+ };
206
+
207
+ const resolutionId = nextResolutionId(tree.manifest, now);
208
+ const resolutionRecord = {
209
+ id: resolutionId,
210
+ createdAt: isoNow,
211
+ kind: 'globalAdrTopic',
212
+ topic,
213
+ resolvedByAdrId: projectAdrId,
214
+ supersedes: supersedes.map((s) => ({ slug: s.slug, adrId: s.adrId })),
215
+ ...(typeof reason === 'string' && reason.length > 0 ? { reason } : {}),
216
+ };
217
+
218
+ if (!dryRun) {
219
+ // Refuse to clobber an existing file at the scaffolded ADR path.
220
+ const already = await stat(absAdrPath).catch(() => null);
221
+ if (already) {
222
+ return rcfError({
223
+ kind: 'duplicateId',
224
+ message: `refuse to overwrite existing file at ${projectAdrPath}. Re-run supersede after removing or renaming that file, or edit it directly if it is the intended supersession record.`,
225
+ filePath: projectAdrPath,
226
+ });
227
+ }
228
+ try {
229
+ await mkdir(dirname(absAdrPath), { recursive: true });
230
+ const tmp = `${absAdrPath}.tmp`;
231
+ await writeFile(tmp, `${JSON.stringify(adrBody, null, 2)}\n`, 'utf8');
232
+ try {
233
+ await rename(tmp, absAdrPath);
234
+ } catch (err) {
235
+ await unlink(tmp).catch(() => {});
236
+ throw err;
237
+ }
238
+ } catch (err) {
239
+ return rcfError({ kind: 'ioFailure', message: `ADR write failed: ${err.message}`, filePath: projectAdrPath });
240
+ }
241
+ }
242
+
243
+ const manifestResult = await updateManifest({
244
+ projectRoot,
245
+ manifest: tree.manifest,
246
+ mutate: (next) => {
247
+ const list = Array.isArray(next.resolutions) ? next.resolutions : [];
248
+ list.push(resolutionRecord);
249
+ next.resolutions = list;
250
+ },
251
+ dryRun,
252
+ });
253
+ if (manifestResult.kind) return manifestResult;
254
+
255
+ return {
256
+ superseded: true,
257
+ topic,
258
+ resolvedByAdrId: projectAdrId,
259
+ resolvedByAdrPath: projectAdrPath,
260
+ supersedes: supersedes.map((s) => ({ slug: s.slug, adrId: s.adrId, path: s.path })),
261
+ resolutionId,
262
+ };
263
+ }
264
+
265
+ function mintProjectAdrId(tree, topic) {
266
+ let maxN = 0;
267
+ const walk = (id) => {
268
+ if (typeof id !== 'string') return;
269
+ const m = id.match(/^ADR-(\d{3,})(?:-|$)/);
270
+ if (!m) return;
271
+ const n = Number.parseInt(m[1], 10);
272
+ if (Number.isFinite(n) && n > maxN) maxN = n;
273
+ };
274
+ if (tree.byId instanceof Map) {
275
+ for (const id of tree.byId.keys()) walk(id);
276
+ }
277
+ // Belt and braces: also scan applied blueprint contribution ids in
278
+ // case an ADR sits recorded but the walker did not surface it under
279
+ // byId (broken tree path, mid-migration state, etc.).
280
+ for (const bp of tree.manifest?.blueprints ?? []) {
281
+ for (const c of bp.contributions ?? []) walk(c.id);
282
+ }
283
+ const next = (maxN + 1).toString().padStart(3, '0');
284
+ // adrId grammar requires lowercase kebab after the digits
285
+ // (^ADR-\d{3,}(-[a-z0-9]+(?:-[a-z0-9]+)*)?$), so a camelCase topic
286
+ // like 'authModel' becomes the slug tail 'auth-model'. Topic itself
287
+ // remains stored verbatim on manifest.resolutions[].topic — it is a
288
+ // lookup key, not a slug.
289
+ return `ADR-${next}-${kebabise(topic)}`;
290
+ }
291
+
292
+ /**
293
+ * Convert a topic string to a well-formed kebab slug suitable for use
294
+ * as the adrId slug-tail (lowercase alnum segments joined by single
295
+ * hyphens; leading letter enforced by the ADR grammar's optional-tail
296
+ * regex when the tail is non-empty). Handles:
297
+ * - camelCase -> kebab-case (`authModel` -> `auth-model`)
298
+ * - snake / SCREAMING_SNAKE -> kebab (`error_envelope` -> `error-envelope`)
299
+ * - existing kebab (`auth-model`) -> unchanged
300
+ * - spaces / punctuation collapsed to single hyphens; leading/trailing
301
+ * hyphens trimmed
302
+ *
303
+ * Guaranteed to return a string that matches `[a-z0-9]+(?:-[a-z0-9]+)*`
304
+ * or empty; the empty case is refused by the caller (topic non-empty
305
+ * check runs first).
306
+ */
307
+ export function kebabise(topic) {
308
+ if (typeof topic !== 'string') return '';
309
+ let s = topic;
310
+ // camelCase -> insert hyphen before each uppercase letter that
311
+ // follows a lowercase letter or digit (`authModel` -> `auth-Model`).
312
+ s = s.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
313
+ // SCREAMING_CASE runs: `ABc` -> `A-Bc` so acronyms split cleanly.
314
+ s = s.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2');
315
+ // Lowercase, collapse non-alnum runs to single hyphen, trim.
316
+ s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
317
+ return s;
318
+ }
@@ -85,6 +85,12 @@ const V1_INVARIANTS = [
85
85
  perThemeOnly: false,
86
86
  run: checkSharedLayoutStructural,
87
87
  },
88
+ {
89
+ name: 'noInlineStyleBlocks',
90
+ severity: 'block',
91
+ perThemeOnly: false,
92
+ run: checkNoInlineStyleBlocks,
93
+ },
88
94
  ];
89
95
 
90
96
  /** Versioned constant, module-encoded per §12 O-6. */
@@ -253,13 +259,16 @@ function checkThemeDefaultsToLight(ctx) {
253
259
  }
254
260
 
255
261
  function checkFocusRingsVisible(ctx) {
256
- // Cheap chain-shaped heuristic: the response HTML mentions a focus
257
- // style (either an inline focus-ring rule or a class that reads as
258
- // one). The real check happens agent-side against a tab-focused
259
- // screenshot; v1 records `advisory` when the heuristic is
260
- // inconclusive so operators know to eyeball. spec §8.3 severity `warn`.
262
+ // Cheap chain-shaped heuristic: the response HTML either mentions a
263
+ // focus style inline OR links a same-origin stylesheet where the
264
+ // rule can live (per the styled-under-shipped-CSP baseline: no
265
+ // inline <style>, ship rules via <link rel="stylesheet">). The real
266
+ // check happens agent-side against a tab-focused screenshot; v1
267
+ // records `warn` when the heuristic is inconclusive so operators
268
+ // know to eyeball. spec §8.3 severity `warn`.
261
269
  if (/focus-visible|focus-ring|outline\s*:/i.test(ctx.dom)) return { verdict: 'pass' };
262
- return { verdict: 'warn', detail: 'no focus-ring style detected in the response HTML (visually confirm agent-side)' };
270
+ if (/<link\b[^>]*rel=["']?stylesheet\b/i.test(ctx.dom)) return { verdict: 'pass' };
271
+ return { verdict: 'warn', detail: 'no focus-ring style detected in the response HTML and no <link rel="stylesheet"> reference (visually confirm agent-side)' };
263
272
  }
264
273
 
265
274
  function checkSharedLayoutStructural(ctx) {
@@ -299,6 +308,24 @@ export function compareTopLevelStructure(captures, baseline) {
299
308
  };
300
309
  }
301
310
 
311
+ /**
312
+ * The captured DOM MUST NOT carry an inline <style> block. A <style>
313
+ * block requires the shipping CSP to allow style-src 'unsafe-inline';
314
+ * a project that ships one either weakens the CSP baseline (a security
315
+ * regression) or renders unstyled at deploy (the watchpost run4 class
316
+ * defect this invariant refuses). Stylesheets must be served from
317
+ * same-origin routes and referenced via <link rel="stylesheet"> so
318
+ * style-src 'self' remains sufficient.
319
+ */
320
+ function checkNoInlineStyleBlocks(ctx) {
321
+ const dom = typeof ctx.dom === 'string' ? ctx.dom : '';
322
+ if (!/<style\b/i.test(dom)) return { verdict: 'pass' };
323
+ return {
324
+ verdict: 'fail',
325
+ detail: 'inline <style> block present in the rendered HTML; ship style-src \'unsafe-inline\' would be required for it to render (watchpost run4 class defect, w-2026-08-24-003). Serve the stylesheet from a same-origin route and reference it via <link rel="stylesheet">.',
326
+ };
327
+ }
328
+
302
329
  function topLevelChildTagSequence(dom) {
303
330
  // Extract the tag-name sequence of direct children of <body>. Cheap
304
331
  // regex-based; deliberately conservative (misses nested body-siblings
@@ -11,6 +11,7 @@
11
11
  // authored acIds.
12
12
 
13
13
  import { byBuildOrder, computeQueue } from './queue.js';
14
+ import { selectStandards } from './standards-selector.js';
14
15
 
15
16
  /**
16
17
  * Copy `fields` from `source` onto a new object, skipping fields not
@@ -106,18 +107,23 @@ export function assembleBundle(tree, { fbsId }) {
106
107
  }
107
108
 
108
109
  // Section 5 - architectural context (§D11). Omitted entirely when
109
- // contextRequirements is absent.
110
- let context;
110
+ // contextRequirements is absent, EXCEPT when the FBS drives an agentic
111
+ // standards selection off manifest.standards[] alone (0.4.4). In that
112
+ // case a minimal context is synthesised so the standards payload has
113
+ // somewhere to land.
114
+ const manifestStandards = tree.manifest?.standards ?? [];
111
115
  const ctx = fbs.contextRequirements;
112
- if (ctx) {
113
- const tacs = (ctx.tacIds ?? [])
116
+ let context;
117
+ if (ctx || (manifestStandards.length > 0 && !fbs.contextRequirements)) {
118
+ const safeCtx = ctx ?? {};
119
+ const tacs = (safeCtx.tacIds ?? [])
114
120
  .map((id) => tree.byId.get(id))
115
121
  .filter(Boolean)
116
122
  .map((tac) => pick(tac, [
117
123
  'tacId', 'name', 'purpose', 'responsibilities', 'interfaces',
118
124
  'dependencies', 'tradeoffs', 'notes',
119
125
  ]));
120
- const adrs = (ctx.adrIds ?? [])
126
+ const adrs = (safeCtx.adrIds ?? [])
121
127
  .map((id) => tree.byId.get(id))
122
128
  .filter(Boolean)
123
129
  .map((adr) => pick(adr, [
@@ -136,19 +142,36 @@ export function assembleBundle(tree, { fbsId }) {
136
142
  }
137
143
  return out;
138
144
  };
139
- const tadSections = resolveSections(ctx.tadSections, tree.tad);
140
- const prdSections = resolveSections(ctx.prdSections, tree.prd);
145
+ const tadSections = resolveSections(safeCtx.tadSections, tree.tad);
146
+ const prdSections = resolveSections(safeCtx.prdSections, tree.prd);
147
+
148
+ // 0.4.4 selective retrieval. Operator-authored standardIds override
149
+ // the agentic selection; empty selection is legitimate and never
150
+ // blocks the assembler.
151
+ const authoredStandardIds = Array.isArray(safeCtx.standardIds) ? safeCtx.standardIds : null;
152
+ const selection = authoredStandardIds
153
+ ? { standardIds: authoredStandardIds, source: 'operatorOverride' }
154
+ : { standardIds: selectStandards(fbs, manifestStandards).standardIds, source: 'agenticSelection' };
155
+ const standardsById = new Map(manifestStandards.map((s) => [s.slug, s]));
156
+ const standardsPayload = selection.standardIds
157
+ .map((slug) => standardsById.get(slug))
158
+ .filter(Boolean)
159
+ .map((pack) => pick(pack, ['id', 'slug', 'tags', 'summary', 'testsProvidedBy', 'provenance', 'sourcePath', 'copyPath']));
160
+
141
161
  context = {
142
162
  tacs,
143
163
  adrs,
144
164
  tadSections,
145
165
  prdSections,
146
166
  unresolvedSections,
167
+ standardIds: selection.standardIds,
168
+ standardsSource: selection.source,
169
+ standards: standardsPayload,
147
170
  passThrough: {
148
- existingModules: ctx.existingModules ?? [],
149
- schemas: ctx.schemas ?? [],
150
- externalDocs: ctx.externalDocs ?? [],
151
- other: ctx.other ?? [],
171
+ existingModules: safeCtx.existingModules ?? [],
172
+ schemas: safeCtx.schemas ?? [],
173
+ externalDocs: safeCtx.externalDocs ?? [],
174
+ other: safeCtx.other ?? [],
152
175
  },
153
176
  };
154
177
  }
@@ -0,0 +1,52 @@
1
+ // Deterministic tag-scoring standards selector for the bundle assembler.
2
+ //
3
+ // Design brief §Selective per-FBS retrieval (agentic): a small model
4
+ // call is allowed but not required. Phase 1 ships a pure heuristic —
5
+ // deterministic tag-hit counting over the FBS work text against the
6
+ // registered standards' tags, slugs and summaries. This satisfies the
7
+ // selective-retrieval ACs (deterministic, hermetic, never blocks) and
8
+ // keeps the seam clean for a smarter selector to slot in.
9
+ //
10
+ // Selection order: standards manifest order (authored); ties inside a
11
+ // score break by manifest order too. Silence on the FBS side returns an
12
+ // empty selection; the bundle assembler treats that as "no standards".
13
+
14
+ /**
15
+ * @param {object} fbs - the FBS document
16
+ * @param {Array<{ slug: string, tags?: string[], summary?: string }>} standards
17
+ * @returns {{ standardIds: string[], scoresById: Record<string, number> }}
18
+ */
19
+ export function selectStandards(fbs, standards) {
20
+ const source = normaliseText([
21
+ fbs?.title,
22
+ fbs?.summary,
23
+ fbs?.approach,
24
+ fbs?.notes,
25
+ (fbs?.acceptanceCriteria ?? []).map((ac) => `${ac?.description ?? ''} ${ac?.given ?? ''} ${ac?.when ?? ''} ${ac?.then ?? ''}`).join(' '),
26
+ ].filter(Boolean).join(' '));
27
+ const packs = Array.isArray(standards) ? standards : [];
28
+ const scoresById = {};
29
+ const selected = [];
30
+ for (const pack of packs) {
31
+ const score = scorePack(source, pack);
32
+ scoresById[pack.slug] = score;
33
+ if (score > 0) selected.push(pack.slug);
34
+ }
35
+ return { standardIds: selected, scoresById };
36
+ }
37
+
38
+ function scorePack(source, pack) {
39
+ let score = 0;
40
+ const tokens = new Set();
41
+ for (const tag of pack.tags ?? []) tokens.add(String(tag).toLowerCase());
42
+ tokens.add(String(pack.slug ?? '').toLowerCase());
43
+ for (const token of tokens) {
44
+ if (!token) continue;
45
+ if (source.includes(token)) score += 1;
46
+ }
47
+ return score;
48
+ }
49
+
50
+ function normaliseText(s) {
51
+ return String(s ?? '').toLowerCase();
52
+ }