rcf-lite 0.16.0 → 0.18.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 (94) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/blueprints/application-api-rest/README.md +5 -1
  3. package/blueprints/application-api-rest/blueprint.json +1 -1
  4. package/blueprints/application-api-rest/contributions/requirements/application-api-rest-req-006.json +4 -4
  5. package/blueprints/application-api-rest/contributions/tacs/tac-306-application-api-rest-operability.json +9 -8
  6. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2103.json +3 -3
  7. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2108.json +27 -27
  8. package/blueprints/application-api-rest/docs/topics.md +2 -2
  9. package/blueprints/application-api-rest/guide/application-api-rest.md +5 -1
  10. package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
  11. package/blueprints/application-spa/blueprint.json +24 -2
  12. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
  13. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
  14. package/blueprints/application-spa/docs/topics.md +2 -2
  15. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
  16. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
  17. package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
  18. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -0
  19. package/blueprints/delivery-ci-workflows/docs/topics.md +2 -2
  20. package/blueprints/deploy-cloudflare-workers/docs/topics.md +2 -2
  21. package/blueprints/email-smtp-resend/docs/topics.md +2 -2
  22. package/blueprints/observability-essentials/README.md +6 -2
  23. package/blueprints/observability-essentials/blueprint.json +133 -33
  24. package/blueprints/observability-essentials/contributions/adrs/adr-801-observability-essentials-health-probes.json +4 -4
  25. package/blueprints/observability-essentials/contributions/adrs/adr-802-observability-essentials-readiness-semantics.json +4 -4
  26. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-001.json +4 -4
  27. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-002.json +4 -4
  28. package/blueprints/observability-essentials/contributions/tacs/tac-801-observability-essentials-liveness-probe.json +10 -9
  29. package/blueprints/observability-essentials/contributions/tacs/tac-802-observability-essentials-readiness-probe.json +17 -11
  30. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7101.json +12 -3
  31. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7102.json +12 -3
  32. package/blueprints/observability-essentials/docs/topics.md +15 -8
  33. package/blueprints/observability-essentials/guide/observability-essentials.md +9 -3
  34. package/blueprints/observability-probe-endpoints/README.md +5 -1
  35. package/blueprints/observability-probe-endpoints/blueprint.json +116 -24
  36. package/blueprints/observability-probe-endpoints/contributions/adrs/adr-1503-observability-probe-endpoints-kubernetes-default.json +5 -5
  37. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14102.json +11 -2
  38. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14107.json +11 -2
  39. package/blueprints/observability-probe-endpoints/docs/topics.md +6 -6
  40. package/blueprints/observability-probe-endpoints/guide/observability-probe-endpoints.md +10 -0
  41. package/blueprints/persistence-data-d1/docs/topics.md +2 -2
  42. package/blueprints/persistence-data-sqlite/docs/topics.md +2 -2
  43. package/blueprints/security-auth-clerk/docs/topics.md +2 -2
  44. package/blueprints/security-auth-keycloak/docs/topics.md +2 -2
  45. package/blueprints/security-auth-magic-link/docs/topics.md +2 -2
  46. package/blueprints/security-auth-oauth2/docs/topics.md +2 -2
  47. package/blueprints/security-secrets-management/docs/topics.md +2 -2
  48. package/fixtures/canary-manifest.json +9 -9
  49. package/package.json +13 -1
  50. package/rcf/code-nodes/cn-070.json +12 -0
  51. package/rcf/code-nodes/cn-071.json +12 -0
  52. package/rcf/code-nodes/cn-072.json +12 -0
  53. package/rcf/code-nodes/cn-073.json +12 -0
  54. package/rcf/code-nodes/cn-074.json +19 -0
  55. package/rcf/code-nodes/cn-075.json +15 -0
  56. package/rcf/code-nodes/cn-076.json +14 -0
  57. package/rcf/fbs/fbs-020.json +18 -0
  58. package/rcf/fbs/fbs-021.json +18 -0
  59. package/rcf/fbs/fbs-022.json +18 -0
  60. package/rcf/fbs/fbs-023.json +18 -0
  61. package/rcf/fbs/fbs-024.json +24 -0
  62. package/rcf/fbs/fbs-025.json +25 -0
  63. package/rcf/fbs/fbs-026.json +27 -0
  64. package/rcf/fbs/fbs-027.json +25 -0
  65. package/rcf/prd.json +3 -2
  66. package/rcf/requirements/req-011.json +22 -0
  67. package/rcf/requirements/req-012.json +22 -0
  68. package/rcf/test-suites/ts-030.json +66 -0
  69. package/rcf/test-suites/ts-031.json +59 -0
  70. package/rcf/test-suites/ts-032.json +50 -0
  71. package/rcf/test-suites/ts-033.json +83 -0
  72. package/rcf/test-suites/ts-034.json +32 -0
  73. package/rcf/test-suites/ts-035.json +23 -0
  74. package/rcf/test-suites/ts-036.json +65 -0
  75. package/rcf/test-suites/ts-037.json +55 -0
  76. package/rcf/user-stories/us-1101.json +51 -0
  77. package/rcf/user-stories/us-1102.json +60 -0
  78. package/rcf/user-stories/us-1103.json +51 -0
  79. package/rcf/user-stories/us-1104.json +60 -0
  80. package/rcf/user-stories/us-1201.json +34 -0
  81. package/rcf/user-stories/us-1202.json +25 -0
  82. package/rcf/user-stories/us-1203.json +43 -0
  83. package/rcf/user-stories/us-1204.json +25 -0
  84. package/releases/releases.yaml +21 -1
  85. package/src/blueprint/index.js +1 -0
  86. package/src/blueprint/remove-resolution.js +104 -0
  87. package/src/blueprint/supersede.js +2 -2
  88. package/src/cli/blueprint.js +41 -0
  89. package/src/cli/doctor.js +257 -5
  90. package/src/cli/init.js +166 -0
  91. package/src/setup/playwright-checks.js +426 -0
  92. package/src/verify/cli/run.js +28 -0
  93. package/src/verify/engine/index.js +24 -4
  94. package/src/verify/engine/launcher.js +41 -10
@@ -0,0 +1,104 @@
1
+ // `rcf define blueprint remove-resolution <adr-id>` implementation.
2
+ //
3
+ // Removes a single entry from `manifest.resolutions[]` (and NOTHING
4
+ // else). The `<adr-id>` argument is matched against the entry's
5
+ // `resolvedByAdrId` field, which is how the doctor's probe-path-owner
6
+ // check (spec section 9) and the four-path resolution card (spec
7
+ // section 4) name the resolution to the operator.
8
+ //
9
+ // Behaviours (spec amendment A2, ratified 2026-09-04):
10
+ // - Removes the resolutions[] entry whose `resolvedByAdrId` equals
11
+ // the argument; leaves every other manifest section untouched. The
12
+ // project-level ADR file at `rcf/adrs/<adr-id>.json` is NOT
13
+ // deleted: an operator who wants to keep the ruling ADR as
14
+ // historical context after the redundant resolution goes away has
15
+ // that path open; an operator who wants the ADR gone can rm it
16
+ // themselves.
17
+ // - Refuses exit 2 when the argument is not a resolution entry on
18
+ // this manifest. Two flavours count as "not a resolution entry":
19
+ // the id is malformed (fails the ADR-\d{3,}(-<kebab-tail>)? grammar),
20
+ // or the id is well-formed but names no ADR anywhere on the
21
+ // tree AND is not present under any resolutions[] entry.
22
+ // - Idempotent on a second run: when the id is a well-formed ADR
23
+ // id that names an ADR present on the tree (the ruling ADR that
24
+ // the resolution had pointed at) but is not (any longer) present
25
+ // under any resolutions[] entry, the module returns
26
+ // `{ removed: false, alreadyAbsent: true }` and the CLI edge
27
+ // prints "nothing to remove" and exits 0. The distinction is:
28
+ // the ruling ADR still exists on disk, so the operator is running
29
+ // the SAME operation a second time, not typing a bogus id.
30
+ //
31
+ // The verb never touches project ADR files, blueprint records, or any
32
+ // other manifest section. That keeps the scope narrow enough that the
33
+ // operator can reason about the write without reading the module.
34
+
35
+ import { isRcfError, rcfError } from '../core/errors/index.js';
36
+ import { updateManifest } from './manifest-writer.js';
37
+
38
+ const ADR_ID_PATTERN = /^ADR-\d{3,}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/;
39
+
40
+ /**
41
+ * @typedef {object} RemoveResolutionResult
42
+ * @property {boolean} removed
43
+ * @property {boolean} [alreadyAbsent]
44
+ * @property {string} resolvedByAdrId
45
+ * @property {string} [resolutionId]
46
+ * @property {string} [topic]
47
+ */
48
+
49
+ /**
50
+ * @param {object} args
51
+ * @param {string} args.projectRoot
52
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
53
+ * @param {string} args.resolvedByAdrId
54
+ * @param {boolean} [args.dryRun]
55
+ * @returns {Promise<RemoveResolutionResult | import('../core/errors/index.js').RcfError>}
56
+ */
57
+ export async function removeResolution({ projectRoot, tree, resolvedByAdrId, dryRun = false }) {
58
+ if (typeof resolvedByAdrId !== 'string' || resolvedByAdrId.trim().length === 0) {
59
+ return rcfError({ kind: 'usage', message: `<adr-id> is required (e.g. rcf define blueprint remove-resolution ADR-011-health-probes).` });
60
+ }
61
+ if (!ADR_ID_PATTERN.test(resolvedByAdrId)) {
62
+ return rcfError({ kind: 'usage', message: `'${resolvedByAdrId}' is not a well-formed ADR id (grammar: ADR-\\d{3,}(-<kebab-tail>)?).` });
63
+ }
64
+ const manifest = tree.manifest ?? {};
65
+ const resolutions = Array.isArray(manifest.resolutions) ? manifest.resolutions : [];
66
+ const index = resolutions.findIndex((r) => r?.resolvedByAdrId === resolvedByAdrId);
67
+ if (index === -1) {
68
+ // Not on resolutions[]. Two branches:
69
+ // - the ruling ADR file exists on the tree: idempotent no-op
70
+ // (this is a re-run of the same operation).
71
+ // - no ADR by that id anywhere: refuse. The operator has typed
72
+ // an id that this project has no record of, either as a
73
+ // resolution entry or as a project ADR.
74
+ const adrPresent = tree.byId instanceof Map && tree.byId.has(resolvedByAdrId);
75
+ if (adrPresent) {
76
+ return { removed: false, alreadyAbsent: true, resolvedByAdrId };
77
+ }
78
+ return rcfError({
79
+ kind: 'usage',
80
+ message: `'${resolvedByAdrId}' is not a resolution entry on this manifest (no resolutions[] record names it as resolvedByAdrId, and no ADR by that id exists on the project tree).`,
81
+ });
82
+ }
83
+ const target = resolutions[index];
84
+ const result = await updateManifest({
85
+ projectRoot,
86
+ manifest,
87
+ mutate: (next) => {
88
+ const list = Array.isArray(next.resolutions) ? next.resolutions : [];
89
+ list.splice(index, 1);
90
+ // Drop the field entirely when empty so the manifest shape stays
91
+ // as compact as it was before any resolution was ever recorded.
92
+ if (list.length === 0) delete next.resolutions;
93
+ else next.resolutions = list;
94
+ },
95
+ dryRun,
96
+ });
97
+ if (isRcfError(result)) return result;
98
+ return {
99
+ removed: true,
100
+ resolvedByAdrId,
101
+ resolutionId: typeof target?.id === 'string' ? target.id : undefined,
102
+ topic: typeof target?.topic === 'string' ? target.topic : undefined,
103
+ };
104
+ }
@@ -26,7 +26,7 @@
26
26
  // the refused-add state; the escalation the worker adapted around was
27
27
  // that AC-1002-5 could not pass as written. The `--incoming` argument
28
28
  // was subsequently fed directly to `loadBlueprint`, which understands
29
- // paths only a newcomer who intuited the `@stock/<slug>` form from
29
+ // paths only; a newcomer who intuited the `@stock/<slug>` form from
30
30
  // `add`'s help hit a refusal. Persona re-run 2026-08-31 arc-4, H2.
31
31
  //
32
32
  // Two side effects, both governed by dryRun:
@@ -171,7 +171,7 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
171
171
  // For library-qualified sources the applied identity is rewired
172
172
  // under the library prefix (`<libraryPrefix>-<blueprintSlug>`), so
173
173
  // the supersedes[] entry must reference that effective slug and
174
- // stamp the ADR id under it matching what `apply.js` writes to
174
+ // stamp the ADR id under it; matching what `apply.js` writes to
175
175
  // `manifest.blueprints[].slug`. For shelf / path sources the
176
176
  // blueprint's own slug applies.
177
177
  const effectiveSlug = resolved.kind === 'library'
@@ -20,6 +20,7 @@ import {
20
20
  groupRowsByCategory,
21
21
  listBlueprints,
22
22
  removeBlueprint,
23
+ removeResolution,
23
24
  renderDiff,
24
25
  resolveBlueprintSource,
25
26
  supersedeBlueprintTopic,
@@ -66,6 +67,20 @@ Verbs:
66
67
  diff <topic> Side-by-side view of every applied blueprint's
67
68
  scope:global ADR on <topic>: id, path, title,
68
69
  status, decision. Read-only.
70
+ remove-resolution <adr-id>
71
+ Remove a single manifest.resolutions[] entry by
72
+ its resolvedByAdrId (the id the doctor's
73
+ probe-path-owner check names when a historical
74
+ resolution has become redundant after a
75
+ blueprint upgrade). Writes nothing else: the
76
+ project-level ADR file at rcf/adrs/<adr-id>.json
77
+ is left in place as historical context. Refuses
78
+ exit 2 when <adr-id> is malformed or names no
79
+ ADR on the project tree. Idempotent on re-run:
80
+ when <adr-id> is a well-formed ADR id that
81
+ names an ADR present on the tree but is no
82
+ longer on any resolutions[] entry, prints
83
+ "nothing to remove" and exits 0.
69
84
  library <verb> Manage external blueprint libraries. Sub-verbs:
70
85
  add, list, remove, refresh. See
71
86
  'rcf define blueprint library --help' for the
@@ -364,6 +379,32 @@ export async function main(argv, deps = {}) {
364
379
  return 0;
365
380
  }
366
381
 
382
+ if (verb === 'remove-resolution') {
383
+ if (rest.length === 0) {
384
+ stderr.write('[error] blueprint remove-resolution: missing <adr-id>\n');
385
+ return 2;
386
+ }
387
+ const adrId = rest[0];
388
+ const result = await removeResolution({
389
+ projectRoot, tree, resolvedByAdrId: adrId,
390
+ dryRun: parsed.values['dry-run'] === true,
391
+ });
392
+ if (isRcfError(result)) {
393
+ stderr.write(`[error] blueprint remove-resolution: ${result.message}\n`);
394
+ return 2;
395
+ }
396
+ if (result.alreadyAbsent) {
397
+ if (!parsed.values.quiet) stdout.write(`[blueprint] nothing to remove: '${adrId}' is not on manifest.resolutions[].\n`);
398
+ return 0;
399
+ }
400
+ if (!parsed.values.quiet) {
401
+ const topicSuffix = result.topic ? ` (topic '${result.topic}')` : '';
402
+ const idSuffix = result.resolutionId ? `; dropped resolution ${result.resolutionId}` : '';
403
+ stdout.write(`[blueprint] removed resolution for '${adrId}'${topicSuffix}${idSuffix}.\n`);
404
+ }
405
+ return 0;
406
+ }
407
+
367
408
  stderr.write(`[error] blueprint: unknown verb '${verb}'\n`);
368
409
  stderr.write(HELP);
369
410
  return 2;
package/src/cli/doctor.js CHANGED
@@ -47,6 +47,16 @@ import {
47
47
  } from '../setup/managed-gitignore.js';
48
48
  import { identityProfilePath } from '../setup/identity-seed.js';
49
49
  import { knowledgePaths } from '../setup/knowledge-seed.js';
50
+ import {
51
+ checkBrowserPresent,
52
+ checkPlaywrightMcpReachable,
53
+ checkPlaywrightPresent,
54
+ findProjectPlaywrightKey,
55
+ FIX_LINES,
56
+ loadBrowserFacingSources,
57
+ probeClaudeCodeMcp,
58
+ SKIP_LINE_NON_BROWSER_FACING,
59
+ } from '../setup/playwright-checks.js';
50
60
 
51
61
  const OPTION_SPEC = {
52
62
  fix: { type: 'boolean' },
@@ -57,7 +67,25 @@ const OPTION_SPEC = {
57
67
  help: { type: 'boolean' },
58
68
  };
59
69
 
60
- const KNOWN_CHECKS = /** @type {const} */ (['agent-instructions', 'gitignore', 'knowledge', 'identity']);
70
+ const KNOWN_CHECKS = /** @type {const} */ ([
71
+ 'agent-instructions',
72
+ 'gitignore',
73
+ 'knowledge',
74
+ 'identity',
75
+ 'playwright-present',
76
+ 'browser-present',
77
+ 'playwright-mcp-reachable',
78
+ 'playwright-mcp-redundant',
79
+ 'probe-path-owner',
80
+ ]);
81
+
82
+ /** The four Playwright-related checks doctor runs conditionally for
83
+ * browser-facing projects (spec 2026-09-03, section 3). */
84
+ const PLAYWRIGHT_CHECKS = /** @type {const} */ ([
85
+ 'playwright-present',
86
+ 'browser-present',
87
+ 'playwright-mcp-reachable',
88
+ ]);
61
89
 
62
90
  export const HELP = `Usage: rcf doctor [--fix] [--check <check>[,check]] [--json] [--quiet] [--help]
63
91
 
@@ -73,8 +101,10 @@ Options:
73
101
  removing the corrupted region.
74
102
  --check <check>[,check] Run only the named checks. Default: all.
75
103
  Values: agent-instructions, gitignore,
76
- knowledge, identity.
77
- --json Emit machine-readable envelope: { ok, drift[] }.
104
+ knowledge, identity, playwright-present,
105
+ browser-present, playwright-mcp-reachable,
106
+ playwright-mcp-redundant, probe-path-owner.
107
+ --json Emit machine-readable envelope: { ok, drift, writes, notices }.
78
108
  --quiet Only summary line + first 3 drift items.
79
109
  --force Accept a legacy-markers --fix on hand-edited
80
110
  content that a non-interactive run would
@@ -155,12 +185,48 @@ export async function main(argv, deps = {}) {
155
185
  force: Boolean(flags.force),
156
186
  // isTty is deps-injectable for tests; falls back to real stdout.
157
187
  isTty: deps.isTty ?? Boolean(stdout.isTTY),
188
+ // Doctor's Playwright-check seams (spec 2026-09-03, section 3). Every
189
+ // probe is injectable so the unit suite runs with none of these tools
190
+ // installed on the runner.
191
+ checkPlaywrightPresentImpl: deps.checkPlaywrightPresent ?? checkPlaywrightPresent,
192
+ checkBrowserPresentImpl: deps.checkBrowserPresent ?? checkBrowserPresent,
193
+ checkPlaywrightMcpReachableImpl:
194
+ deps.checkPlaywrightMcpReachable ?? checkPlaywrightMcpReachable,
195
+ probeClaudeCodeMcpImpl: deps.probeClaudeCodeMcp ?? probeClaudeCodeMcp,
196
+ loadBrowserFacingSourcesImpl:
197
+ deps.loadBrowserFacingSources ?? loadBrowserFacingSources,
198
+ readMcpJsonImpl: deps.readMcpJson ?? defaultReadMcpJson,
158
199
  };
159
200
 
201
+ // Section 3.1: browser-facing projection. Computed once; every Playwright
202
+ // check reads the result from ctx rather than re-walking the manifest.
203
+ const browserFacingResult = await ctx.loadBrowserFacingSourcesImpl(cwd);
204
+ ctx.browserFacing = Boolean(browserFacingResult.browserFacing);
205
+ ctx.browserFacingSources = browserFacingResult.sources ?? [];
206
+
160
207
  /** @type {Array<{check: string, item: string, file: string, message: string, refusedByFix: boolean}>} */
161
208
  const drift = [];
162
209
  /** @type {Array<{file: string, action: string}>} */
163
210
  const writes = [];
211
+ /** @type {string[]} */
212
+ const notices = [];
213
+
214
+ // Section 3.4: skip line for the three Playwright checks on non-browser-
215
+ // facing projects. Emitted when at least one of the three is enabled but
216
+ // the project is not browser-facing AND the operator did not explicitly ask
217
+ // for that check by name (spec 3.5). We honour the --check filter by
218
+ // detecting whether the operator explicitly named any playwright check.
219
+ const explicitlyPickedPlaywrightChecks = new Set(
220
+ enabled.filter((c) => PLAYWRIGHT_CHECKS.includes(c)),
221
+ );
222
+ const anyPlaywrightCheckEnabled = explicitlyPickedPlaywrightChecks.size > 0;
223
+ const operatorAskedByName =
224
+ typeof flags.check === 'string'
225
+ && flags.check.length > 0
226
+ && explicitlyPickedPlaywrightChecks.size > 0;
227
+ if (anyPlaywrightCheckEnabled && !ctx.browserFacing && !operatorAskedByName) {
228
+ notices.push(SKIP_LINE_NON_BROWSER_FACING);
229
+ }
164
230
 
165
231
  for (const check of enabled) {
166
232
  let result;
@@ -168,7 +234,26 @@ export async function main(argv, deps = {}) {
168
234
  else if (check === 'gitignore') result = await runGitignoreCheck(ctx);
169
235
  else if (check === 'knowledge') result = await runKnowledgeCheck(ctx);
170
236
  else if (check === 'identity') result = await runIdentityCheck(ctx);
171
- else continue;
237
+ else if (PLAYWRIGHT_CHECKS.includes(check)) {
238
+ // Skip the check on non-browser-facing projects unless the operator
239
+ // explicitly asked for this check by name (--check filter): the skip
240
+ // line above is the ground-truth diagnostic in that case (spec 3.4/3.5).
241
+ if (!ctx.browserFacing && !operatorAskedByName) continue;
242
+ if (check === 'playwright-present') result = await runPlaywrightPresentCheck(ctx);
243
+ else if (check === 'browser-present') result = await runBrowserPresentCheck(ctx);
244
+ else if (check === 'playwright-mcp-reachable') result = await runPlaywrightMcpReachableCheck(ctx);
245
+ else continue;
246
+ } else if (check === 'playwright-mcp-redundant') {
247
+ // Fires only on browser-facing projects (spec 4.5). Never runs on an
248
+ // API-only project even under an explicit --check ask.
249
+ if (!ctx.browserFacing) continue;
250
+ result = await runPlaywrightMcpRedundantCheck(ctx);
251
+ } else if (check === 'probe-path-owner') {
252
+ // Fires whenever more than one applied blueprint teaches probe paths.
253
+ // Runs on every project (not gated on browser-facing). Spec:
254
+ // projects/rcf-lite-wsd/specs/rcf-lite-probe-path-alignment-spec-2026-09-04.md section 9.
255
+ result = await runProbePathOwnerCheck(ctx);
256
+ } else continue;
172
257
  for (const d of result.drift) drift.push({ check, ...d });
173
258
  for (const w of result.writes) writes.push(w);
174
259
  }
@@ -183,10 +268,15 @@ export async function main(argv, deps = {}) {
183
268
  const exitCode = ok ? 0 : 3;
184
269
 
185
270
  if (flags.json) {
186
- stdout.write(`${JSON.stringify({ ok, drift, writes }, null, 2)}\n`);
271
+ stdout.write(`${JSON.stringify({ ok, drift, writes, notices }, null, 2)}\n`);
187
272
  return exitCode;
188
273
  }
189
274
 
275
+ // Notices (spec 3.4 skip line) are diagnostic ground truth. Emitted BEFORE
276
+ // the summary so an operator scanning the top of the output sees why the
277
+ // three checks did not fire on a non-browser-facing project. Not
278
+ // suppressed by --quiet.
279
+ for (const notice of notices) stdout.write(`${notice}\n`);
190
280
  writeHumanSummary({ stdout, ok, drift, writes, fixed: ctx.fix, quiet: Boolean(flags.quiet) });
191
281
  return exitCode;
192
282
  }
@@ -546,3 +636,165 @@ async function isPathIgnored(projectRoot) {
546
636
 
547
637
  // Silence unused-import warnings for values consumed only via names.
548
638
  void composeGitignoreInner;
639
+
640
+ /* ------------------------------------------------------------------ */
641
+ /* Check: playwright-present (spec 3.3) */
642
+ /* ------------------------------------------------------------------ */
643
+
644
+ async function runPlaywrightPresentCheck(ctx) {
645
+ const drift = [];
646
+ const result = ctx.checkPlaywrightPresentImpl(ctx.projectRoot);
647
+ if (!result.ok) {
648
+ drift.push({
649
+ item: 'missing-peer',
650
+ file: 'package.json',
651
+ message: FIX_LINES['playwright-present'],
652
+ refusedByFix: true,
653
+ });
654
+ }
655
+ return { drift, writes: [] };
656
+ }
657
+
658
+ /* ------------------------------------------------------------------ */
659
+ /* Check: browser-present (spec 3.3) */
660
+ /* ------------------------------------------------------------------ */
661
+
662
+ async function runBrowserPresentCheck(ctx) {
663
+ const drift = [];
664
+ const result = await ctx.checkBrowserPresentImpl();
665
+ if (!result.ok) {
666
+ drift.push({
667
+ item: 'no-browser',
668
+ file: '(system)',
669
+ message: FIX_LINES['browser-present'],
670
+ refusedByFix: true,
671
+ });
672
+ }
673
+ return { drift, writes: [] };
674
+ }
675
+
676
+ /* ------------------------------------------------------------------ */
677
+ /* Check: playwright-mcp-reachable (spec 3.3) */
678
+ /* ------------------------------------------------------------------ */
679
+
680
+ async function runPlaywrightMcpReachableCheck(ctx) {
681
+ const drift = [];
682
+ const result = await ctx.checkPlaywrightMcpReachableImpl();
683
+ if (!result.ok) {
684
+ drift.push({
685
+ item: result.timedOut ? 'unreachable-timeout' : 'unreachable',
686
+ file: '(npx @playwright/mcp)',
687
+ message: FIX_LINES['playwright-mcp-reachable'],
688
+ refusedByFix: true,
689
+ });
690
+ }
691
+ return { drift, writes: [] };
692
+ }
693
+
694
+ /* ------------------------------------------------------------------ */
695
+ /* Check: playwright-mcp-redundant (spec 4.5) */
696
+ /* ------------------------------------------------------------------ */
697
+
698
+ async function runPlaywrightMcpRedundantCheck(ctx) {
699
+ const drift = [];
700
+ const mcpJson = await ctx.readMcpJsonImpl(ctx.projectRoot);
701
+ const projectKey = mcpJson ? findProjectPlaywrightKey(mcpJson) : null;
702
+ if (!projectKey) return { drift, writes: [] };
703
+ const probeResult = await ctx.probeClaudeCodeMcpImpl();
704
+ if (probeResult.kind !== 'found') return { drift, writes: [] };
705
+ drift.push({
706
+ item: 'redundant-entry',
707
+ file: '.mcp.json',
708
+ message: `project-scope .mcp.json carries a Playwright MCP entry ('${projectKey}') that is also declared at ${probeResult.scope} scope. The project entry shadows the user entry. Remove the project entry with \`rcf init --no-playwright-mcp\` (which re-runs init without writing it), or delete the '${projectKey}' entry from .mcp.json by hand.`,
709
+ refusedByFix: true,
710
+ });
711
+ return { drift, writes: [] };
712
+ }
713
+
714
+ /* ------------------------------------------------------------------ */
715
+ /* Check: probe-path-owner (probe-path alignment spec section 9) */
716
+ /* ------------------------------------------------------------------ */
717
+
718
+ /**
719
+ * Fires when more than one applied blueprint teaches probe paths. Reads
720
+ * `rcf/manifest.json` for the applied-blueprint records; counts how many
721
+ * carry a scope:global ADR on `healthProbes` (the shelf-wide probe-path
722
+ * ownership topic). More than one is drift: names the blueprints and the
723
+ * remedy line naming the one that would have to drop its opinion.
724
+ *
725
+ * Also names any resolutions[] entry the alignment removed as redundant
726
+ * (a project that applied essentials v1.x + probe-endpoints v1.0.0 and
727
+ * resolved the two topics via supersede will have a resolution entry
728
+ * pointing at both blueprint ADRs; after re-applying to essentials v2.0.0
729
+ * the essentials side no longer claims the topic and the resolution is
730
+ * redundant historical context the operator can remove).
731
+ */
732
+ async function runProbePathOwnerCheck(ctx) {
733
+ const drift = [];
734
+ const manifestPath = join(ctx.projectRoot, 'rcf', 'manifest.json');
735
+ let manifest = null;
736
+ try {
737
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
738
+ } catch { return { drift, writes: [] }; }
739
+ const applied = Array.isArray(manifest?.blueprints) ? manifest.blueprints : [];
740
+ const claimants = { healthProbes: [], readinessSemantics: [] };
741
+ for (const b of applied) {
742
+ for (const c of b?.contributions ?? []) {
743
+ if (c?.kind === 'adr' && c?.scope === 'global' && (c.topic === 'healthProbes' || c.topic === 'readinessSemantics')) {
744
+ claimants[c.topic].push({ slug: b.slug, adrId: c.id });
745
+ }
746
+ }
747
+ }
748
+ for (const topic of ['healthProbes', 'readinessSemantics']) {
749
+ if (claimants[topic].length > 1) {
750
+ const slugs = claimants[topic].map((x) => x.slug).join(', ');
751
+ const owner = claimants[topic].find((x) => x.slug === 'observability-probe-endpoints');
752
+ const remedy = owner
753
+ ? `${claimants[topic].filter((x) => x.slug !== 'observability-probe-endpoints').map((x) => x.slug).join(', ')} would have to drop its scope:global claim on ${topic} (observability-probe-endpoints is the shelf canonical owner from probe-endpoints v1.0.0)`
754
+ : `one of ${slugs} would have to drop its scope:global claim on ${topic}`;
755
+ drift.push({
756
+ item: `multiple-${topic}-owners`,
757
+ file: 'rcf/manifest.json',
758
+ message: `more than one applied blueprint teaches probe paths on topic '${topic}': ${slugs}. ${remedy}. Spec: projects/rcf-lite-wsd/specs/rcf-lite-probe-path-alignment-spec-2026-09-04.md section 9.`,
759
+ refusedByFix: true,
760
+ });
761
+ }
762
+ }
763
+ // Redundant resolutions: a resolutions[] entry that names both
764
+ // `healthProbes` or `readinessSemantics` where the current claimant
765
+ // count is 1 (or 0) is redundant historical context.
766
+ const resolutions = Array.isArray(manifest?.resolutions) ? manifest.resolutions : [];
767
+ for (const r of resolutions) {
768
+ if (r?.topic === 'healthProbes' || r?.topic === 'readinessSemantics') {
769
+ if (claimants[r.topic].length <= 1) {
770
+ drift.push({
771
+ item: `redundant-${r.topic}-resolution`,
772
+ file: 'rcf/manifest.json',
773
+ message: `resolution '${r?.resolvedByAdrId ?? '(unnamed)'}' on topic '${r.topic}' is now redundant historical context: only ${claimants[r.topic].length} applied blueprint claims the topic after the probe-path alignment. Consider \`rcf define blueprint remove-resolution ${r?.resolvedByAdrId ?? '<id>'}\` or leave the resolution ADR as historical.`,
774
+ refusedByFix: true,
775
+ });
776
+ }
777
+ }
778
+ }
779
+ return { drift, writes: [] };
780
+ }
781
+
782
+
783
+ /**
784
+ * Default reader for the project-root .mcp.json body. Returns the parsed
785
+ * object, or null on missing / unparseable (doctor treats an unparseable
786
+ * .mcp.json as "no signature findable" rather than a hard refusal here; the
787
+ * merge path in agent-setup already refuses unparseable with exit 2 on write).
788
+ *
789
+ * @param {string} projectRoot
790
+ * @returns {Promise<object|null>}
791
+ */
792
+ async function defaultReadMcpJson(projectRoot) {
793
+ const file = join(projectRoot, '.mcp.json');
794
+ try {
795
+ const raw = await readFile(file, 'utf8');
796
+ return JSON.parse(raw);
797
+ } catch {
798
+ return null;
799
+ }
800
+ }