rcf-lite 0.17.0 → 0.19.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 (161) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/blueprints/application-api-rest/README.md +5 -1
  3. package/blueprints/application-api-rest/blueprint.json +12 -4
  4. package/blueprints/application-api-rest/contributions/adrs/adr-304-application-api-rest-logging.json +3 -3
  5. package/blueprints/application-api-rest/contributions/requirements/application-api-rest-req-006.json +4 -4
  6. package/blueprints/application-api-rest/contributions/tacs/tac-306-application-api-rest-operability.json +9 -8
  7. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2103.json +3 -3
  8. package/blueprints/application-api-rest/contributions/user-stories/application-api-rest-us-2108.json +27 -27
  9. package/blueprints/application-api-rest/docs/topics.md +6 -4
  10. package/blueprints/application-api-rest/guide/application-api-rest.md +5 -1
  11. package/blueprints/application-error-handling/README.md +42 -0
  12. package/blueprints/application-error-handling/assets/schemas/error-record.schema.json +17 -0
  13. package/blueprints/application-error-handling/blueprint.json +27 -0
  14. package/blueprints/application-error-handling/contributions/adrs/adr-1701-application-error-handling-record-shape.json +25 -0
  15. package/blueprints/application-error-handling/contributions/adrs/adr-1702-application-error-handling-classification-vocabulary.json +20 -0
  16. package/blueprints/application-error-handling/contributions/adrs/adr-1703-application-error-handling-transport-mapping.json +20 -0
  17. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-001.json +15 -0
  18. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-002.json +15 -0
  19. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-003.json +15 -0
  20. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-004.json +15 -0
  21. package/blueprints/application-error-handling/contributions/tacs/tac-1701-application-error-handling-boundary.json +45 -0
  22. package/blueprints/application-error-handling/contributions/tacs/tac-1702-application-error-handling-record-factory.json +40 -0
  23. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16101.json +25 -0
  24. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16102.json +25 -0
  25. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16103.json +34 -0
  26. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16104.json +25 -0
  27. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16105.json +25 -0
  28. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16106.json +25 -0
  29. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16107.json +25 -0
  30. package/blueprints/application-error-handling/docs/topics.md +24 -0
  31. package/blueprints/application-error-handling/guide/application-error-handling.md +35 -0
  32. package/blueprints/application-spa/blueprint.json +12 -2
  33. package/blueprints/application-spa/docs/topics.md +4 -2
  34. package/blueprints/delivery-ci-workflows/docs/topics.md +2 -2
  35. package/blueprints/deploy-cloudflare-workers/docs/topics.md +2 -2
  36. package/blueprints/email-smtp-resend/docs/topics.md +2 -2
  37. package/blueprints/observability-essentials/README.md +6 -2
  38. package/blueprints/observability-essentials/blueprint.json +133 -33
  39. package/blueprints/observability-essentials/contributions/adrs/adr-801-observability-essentials-health-probes.json +4 -4
  40. package/blueprints/observability-essentials/contributions/adrs/adr-802-observability-essentials-readiness-semantics.json +4 -4
  41. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-001.json +4 -4
  42. package/blueprints/observability-essentials/contributions/requirements/observability-essentials-req-002.json +4 -4
  43. package/blueprints/observability-essentials/contributions/tacs/tac-801-observability-essentials-liveness-probe.json +10 -9
  44. package/blueprints/observability-essentials/contributions/tacs/tac-802-observability-essentials-readiness-probe.json +17 -11
  45. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7101.json +12 -3
  46. package/blueprints/observability-essentials/contributions/user-stories/observability-essentials-us-7102.json +12 -3
  47. package/blueprints/observability-essentials/docs/topics.md +15 -8
  48. package/blueprints/observability-essentials/guide/observability-essentials.md +9 -3
  49. package/blueprints/observability-logging/README.md +44 -0
  50. package/blueprints/observability-logging/assets/samples/log-line.json +13 -0
  51. package/blueprints/observability-logging/blueprint.json +27 -0
  52. package/blueprints/observability-logging/contributions/adrs/adr-1601-observability-logging-line-shape.json +25 -0
  53. package/blueprints/observability-logging/contributions/adrs/adr-1602-observability-logging-correlation-id-header.json +25 -0
  54. package/blueprints/observability-logging/contributions/adrs/adr-1603-observability-logging-redaction-categories.json +25 -0
  55. package/blueprints/observability-logging/contributions/adrs/adr-1604-observability-logging-level-vocabulary.json +20 -0
  56. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-001.json +15 -0
  57. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-002.json +15 -0
  58. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-003.json +15 -0
  59. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-004.json +15 -0
  60. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-005.json +15 -0
  61. package/blueprints/observability-logging/contributions/tacs/tac-1601-observability-logging-logger-factory.json +46 -0
  62. package/blueprints/observability-logging/contributions/tacs/tac-1602-observability-logging-redaction-boundary.json +27 -0
  63. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15101.json +34 -0
  64. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15102.json +43 -0
  65. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15103.json +34 -0
  66. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15104.json +25 -0
  67. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15105.json +34 -0
  68. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15106.json +25 -0
  69. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15107.json +25 -0
  70. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15108.json +25 -0
  71. package/blueprints/observability-logging/docs/topics.md +21 -0
  72. package/blueprints/observability-logging/guide/observability-logging.md +36 -0
  73. package/blueprints/observability-probe-endpoints/README.md +5 -1
  74. package/blueprints/observability-probe-endpoints/blueprint.json +116 -24
  75. package/blueprints/observability-probe-endpoints/contributions/adrs/adr-1503-observability-probe-endpoints-kubernetes-default.json +5 -5
  76. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14102.json +11 -2
  77. package/blueprints/observability-probe-endpoints/contributions/user-stories/observability-probe-endpoints-us-14107.json +11 -2
  78. package/blueprints/observability-probe-endpoints/docs/topics.md +6 -6
  79. package/blueprints/observability-probe-endpoints/guide/observability-probe-endpoints.md +10 -0
  80. package/blueprints/persistence-data-d1/docs/topics.md +2 -2
  81. package/blueprints/persistence-data-sqlite/docs/topics.md +2 -2
  82. package/blueprints/security-auth-clerk/docs/topics.md +2 -2
  83. package/blueprints/security-auth-keycloak/docs/topics.md +2 -2
  84. package/blueprints/security-auth-magic-link/docs/topics.md +2 -2
  85. package/blueprints/security-auth-oauth2/docs/topics.md +2 -2
  86. package/blueprints/security-secrets-management/docs/topics.md +2 -2
  87. package/fixtures/canary-manifest.json +6 -6
  88. package/guidance/harness-template.md +8 -0
  89. package/guidance/managed/agent-instructions-block.hash +1 -1
  90. package/guidance/managed/agent-instructions-block.md +8 -0
  91. package/package.json +1 -1
  92. package/rcf/code-nodes/cn-074.json +19 -0
  93. package/rcf/code-nodes/cn-075.json +15 -0
  94. package/rcf/code-nodes/cn-076.json +14 -0
  95. package/rcf/code-nodes/cn-077.json +14 -0
  96. package/rcf/code-nodes/cn-078.json +15 -0
  97. package/rcf/code-nodes/cn-079.json +14 -0
  98. package/rcf/code-nodes/cn-080.json +15 -0
  99. package/rcf/code-nodes/cn-081.json +15 -0
  100. package/rcf/code-nodes/cn-082.json +15 -0
  101. package/rcf/code-nodes/cn-083.json +14 -0
  102. package/rcf/code-nodes/cn-084.json +15 -0
  103. package/rcf/code-nodes/cn-085.json +14 -0
  104. package/rcf/code-nodes/cn-086.json +16 -0
  105. package/rcf/code-nodes/cn-087.json +14 -0
  106. package/rcf/code-nodes/cn-088.json +14 -0
  107. package/rcf/code-nodes/cn-089.json +14 -0
  108. package/rcf/code-nodes/cn-090.json +14 -0
  109. package/rcf/code-nodes/cn-091.json +14 -0
  110. package/rcf/code-nodes/cn-092.json +15 -0
  111. package/rcf/code-nodes/cn-093.json +15 -0
  112. package/rcf/code-nodes/cn-094.json +14 -0
  113. package/rcf/code-nodes/cn-095.json +14 -0
  114. package/rcf/code-nodes/cn-096.json +14 -0
  115. package/rcf/code-nodes/cn-097.json +14 -0
  116. package/rcf/fbs/fbs-024.json +24 -0
  117. package/rcf/fbs/fbs-025.json +25 -0
  118. package/rcf/fbs/fbs-026.json +27 -0
  119. package/rcf/fbs/fbs-027.json +25 -0
  120. package/rcf/fbs/fbs-028.json +27 -0
  121. package/rcf/fbs/fbs-029.json +27 -0
  122. package/rcf/fbs/fbs-030.json +27 -0
  123. package/rcf/fbs/fbs-031.json +27 -0
  124. package/rcf/fbs/fbs-032.json +27 -0
  125. package/rcf/fbs/fbs-033.json +27 -0
  126. package/rcf/fbs/fbs-034.json +27 -0
  127. package/rcf/requirements/req-012.json +22 -0
  128. package/rcf/requirements/req-013.json +22 -0
  129. package/rcf/requirements/req-014.json +22 -0
  130. package/rcf/requirements/req-015.json +21 -0
  131. package/rcf/test-suites/ts-034.json +32 -0
  132. package/rcf/test-suites/ts-035.json +23 -0
  133. package/rcf/test-suites/ts-036.json +65 -0
  134. package/rcf/test-suites/ts-037.json +55 -0
  135. package/rcf/test-suites/ts-038.json +41 -0
  136. package/rcf/test-suites/ts-039.json +41 -0
  137. package/rcf/test-suites/ts-040.json +57 -0
  138. package/rcf/test-suites/ts-041.json +57 -0
  139. package/rcf/test-suites/ts-042.json +57 -0
  140. package/rcf/test-suites/ts-043.json +49 -0
  141. package/rcf/test-suites/ts-044.json +41 -0
  142. package/rcf/user-stories/us-1201.json +34 -0
  143. package/rcf/user-stories/us-1202.json +25 -0
  144. package/rcf/user-stories/us-1203.json +43 -0
  145. package/rcf/user-stories/us-1204.json +25 -0
  146. package/rcf/user-stories/us-1301.json +43 -0
  147. package/rcf/user-stories/us-1302.json +43 -0
  148. package/rcf/user-stories/us-1401.json +43 -0
  149. package/rcf/user-stories/us-1402.json +43 -0
  150. package/rcf/user-stories/us-1403.json +43 -0
  151. package/rcf/user-stories/us-1404.json +43 -0
  152. package/rcf/user-stories/us-1501.json +43 -0
  153. package/releases/releases.yaml +21 -1
  154. package/src/blueprint/apply.js +8 -0
  155. package/src/blueprint/companions.js +485 -0
  156. package/src/blueprint/index.js +17 -0
  157. package/src/blueprint/loader.js +243 -1
  158. package/src/blueprint/remove-resolution.js +104 -0
  159. package/src/cli/blueprint.js +350 -0
  160. package/src/cli/doctor.js +76 -1
  161. package/src/cli/validate.js +7 -0
@@ -19,10 +19,20 @@ import {
19
19
  enrichRowsWithCategories,
20
20
  groupRowsByCategory,
21
21
  listBlueprints,
22
+ loadBlueprint,
22
23
  removeBlueprint,
24
+ removeResolution,
23
25
  renderDiff,
24
26
  resolveBlueprintSource,
25
27
  supersedeBlueprintTopic,
28
+ readCompanionsFile,
29
+ setCompanionPin,
30
+ unsetCompanionPin,
31
+ resolveCompanions,
32
+ renderCompanionLines,
33
+ renderAmbiguousLibraryRefusal,
34
+ enumerateShelfProviders,
35
+ enumerateLibraryProviders,
26
36
  } from '../blueprint/index.js';
27
37
  import { conflictReportJson, renderConflictReport } from '../blueprint/conflicts.js';
28
38
  import { handleLibraryVerb, LIBRARY_HELP } from './blueprint-library.js';
@@ -66,6 +76,40 @@ Verbs:
66
76
  diff <topic> Side-by-side view of every applied blueprint's
67
77
  scope:global ADR on <topic>: id, path, title,
68
78
  status, decision. Read-only.
79
+ remove-resolution <adr-id>
80
+ Remove a single manifest.resolutions[] entry by
81
+ its resolvedByAdrId (the id the doctor's
82
+ probe-path-owner check names when a historical
83
+ resolution has become redundant after a
84
+ blueprint upgrade). Writes nothing else: the
85
+ project-level ADR file at rcf/adrs/<adr-id>.json
86
+ is left in place as historical context. Refuses
87
+ exit 2 when <adr-id> is malformed or names no
88
+ ADR on the project tree. Idempotent on re-run:
89
+ when <adr-id> is a well-formed ADR id that
90
+ names an ADR present on the tree but is no
91
+ longer on any resolutions[] entry, prints
92
+ "nothing to remove" and exits 0.
93
+ companions <slug> Print the resolved companion set for the
94
+ named applied service blueprint. Reads its
95
+ source blueprint.json's suggestedCompanions[],
96
+ walks the deterministic tier ladder (applied
97
+ > registered library > core shelf, with
98
+ rcf/companions.json pin overriding library +
99
+ shelf), and prints one line per role. Refuses
100
+ exit 3 when two or more registered libraries
101
+ provide the same role with no pin. \`--json\`
102
+ emits a machine-readable envelope.
103
+ companions set <role> <slug>
104
+ Write a project-level pin to rcf/companions.json
105
+ under roles.<role>.provider. <slug> accepts
106
+ \`<libraryPrefix>:<slug>\` for a library provider
107
+ or a bare kebab slug for a shelf provider.
108
+ Refuses exit 2 when the named provider does
109
+ not declare providesRoles containing <role>.
110
+ companions unset <role>
111
+ Remove a pin from rcf/companions.json. Refuses
112
+ exit 2 when no pin exists for <role>.
69
113
  library <verb> Manage external blueprint libraries. Sub-verbs:
70
114
  add, list, remove, refresh. See
71
115
  'rcf define blueprint library --help' for the
@@ -87,6 +131,21 @@ Options:
87
131
  --reason <text> (supersede, add --resolve) Optional operator
88
132
  note attached to the manifest.resolutions[]
89
133
  record.
134
+ --companion <role>=<slug>
135
+ (add only, repeatable) Pin a companion
136
+ provider at apply time; writes the pin to
137
+ rcf/companions.json (schemaVersion 1, current
138
+ pin per role) with pinnedAt. <slug> is
139
+ \`<libraryPrefix>:<slug>\` for a library
140
+ provider or a bare kebab slug for a shelf
141
+ provider. Refuses exit 2 when the named
142
+ provider does not declare providesRoles
143
+ containing <role>.
144
+ --no-companion-suggestions
145
+ (add only) Suppress both the --companion pin
146
+ phase and the post-apply resolved suggestion
147
+ block; every other apply behaviour is
148
+ unchanged.
90
149
  --json (add only) Emit the result (or conflict
91
150
  report) as a machine-readable JSON object.
92
151
  Exit code is unchanged (0 on apply, 3 on
@@ -122,6 +181,9 @@ const OPTION_SPEC = {
122
181
  'dry-run': { type: 'boolean' },
123
182
  quiet: { type: 'boolean' },
124
183
  help: { type: 'boolean' },
184
+ // Companion-suggestion mechanism (core-companions spec section 2).
185
+ companion: { type: 'string', multiple: true },
186
+ 'no-companion-suggestions': { type: 'boolean' },
125
187
  };
126
188
 
127
189
  /**
@@ -205,6 +267,25 @@ export async function main(argv, deps = {}) {
205
267
  stderr.write(`[error] blueprint add: ${resolveDeclarations.error}\n`);
206
268
  return 2;
207
269
  }
270
+ // Pre-flight --companion selectors (core-companions spec section
271
+ // 5). Validate BEFORE the apply so a bad selector refuses without
272
+ // side effects (the pin write and the applied contributions both
273
+ // stay untouched). --no-companion-suggestions suppresses the
274
+ // pre-flight too.
275
+ const companionSelectorsPre = parseCompanionOptions(parsed.values.companion);
276
+ if (companionSelectorsPre.error) {
277
+ stderr.write(`[error] blueprint add: ${companionSelectorsPre.error}\n`);
278
+ return 2;
279
+ }
280
+ if (!parsed.values['no-companion-suggestions']) {
281
+ for (const sel of companionSelectorsPre.value) {
282
+ const providerCheck = await validateCompanionProvider({ selector: sel, projectRoot, tree });
283
+ if (providerCheck.error) {
284
+ stderr.write(`[error] blueprint add: ${providerCheck.error}\n`);
285
+ return 2;
286
+ }
287
+ }
288
+ }
208
289
  const result = await applyBlueprint({
209
290
  projectRoot, tree, source,
210
291
  namespaceOverride: parsed.values.namespace,
@@ -273,6 +354,57 @@ export async function main(argv, deps = {}) {
273
354
  if (!parsed.values.quiet) {
274
355
  stdout.write(`[blueprint] applied '${result.slug}' at ${result.version} (${result.contributions.length} contribution(s)).\n`);
275
356
  }
357
+ // Companion-suggestion mechanism (spec 2.6). Runs AFTER the apply
358
+ // writes so a failed apply never produces a suggestion block.
359
+ // Composed of two phases: (a) --companion selectors record pins to
360
+ // rcf/companions.json (spec 2.4); (b) print the resolved
361
+ // suggestion block using the current tree + pins + libraries + shelf.
362
+ // Both phases are suppressed by --no-companion-suggestions.
363
+ if (!parsed.values['no-companion-suggestions']) {
364
+ // Re-walk the tree so applied writes are visible to the resolver.
365
+ const { tree: postTree } = await walkTree({ projectRoot });
366
+ // Phase (a): --companion selectors. Pre-flight validated the
367
+ // shape and provider gate before the apply; here we only write
368
+ // the pins.
369
+ for (const sel of companionSelectorsPre.value) {
370
+ const pinRes = await setCompanionPin({ projectRoot, role: sel.role, provider: sel.provider, now });
371
+ if (pinRes.kind) {
372
+ stderr.write(`[error] blueprint add: ${pinRes.message}\n`);
373
+ return 2;
374
+ }
375
+ if (pinRes.previousProvider && pinRes.previousProvider !== sel.provider) {
376
+ stderr.write(`[blueprint] companion pin for role '${sel.role}' updated: '${pinRes.previousProvider}' -> '${sel.provider}'\n`);
377
+ }
378
+ }
379
+ // Phase (b): print the resolved suggestion block for the just-
380
+ // applied service blueprint. Skipped if the applied blueprint
381
+ // declared no suggestedCompanions[].
382
+ if (Array.isArray(result.suggestedCompanions) && result.suggestedCompanions.length > 0) {
383
+ const pins = await readCompanionsFile(projectRoot);
384
+ const pinsClean = pins && pins.kind ? null : pins;
385
+ const resolved = await resolveCompanions({
386
+ projectRoot,
387
+ tree: postTree,
388
+ suggestedCompanions: result.suggestedCompanions,
389
+ pins: pinsClean,
390
+ });
391
+ // Surface ambiguous refusal as a hard exit-3 (spec 2.4).
392
+ const ambiguous = resolved.find((r) => r.origin === 'ambiguousLibraries');
393
+ if (ambiguous) {
394
+ stderr.write(renderAmbiguousLibraryRefusal({
395
+ role: ambiguous.role,
396
+ providers: ambiguous.ambiguousProviders,
397
+ serviceSlug: result.slug,
398
+ }));
399
+ return 3;
400
+ }
401
+ if (!parsed.values.quiet) {
402
+ stdout.write(`\nSuggested companions this blueprint recommends alongside it:\n`);
403
+ stdout.write(`${renderCompanionLines(resolved)}\n`);
404
+ stdout.write(`Apply either with: rcf define blueprint add <slug>\n`);
405
+ }
406
+ }
407
+ }
276
408
  return 0;
277
409
  }
278
410
 
@@ -364,11 +496,229 @@ export async function main(argv, deps = {}) {
364
496
  return 0;
365
497
  }
366
498
 
499
+ if (verb === 'remove-resolution') {
500
+ if (rest.length === 0) {
501
+ stderr.write('[error] blueprint remove-resolution: missing <adr-id>\n');
502
+ return 2;
503
+ }
504
+ const adrId = rest[0];
505
+ const result = await removeResolution({
506
+ projectRoot, tree, resolvedByAdrId: adrId,
507
+ dryRun: parsed.values['dry-run'] === true,
508
+ });
509
+ if (isRcfError(result)) {
510
+ stderr.write(`[error] blueprint remove-resolution: ${result.message}\n`);
511
+ return 2;
512
+ }
513
+ if (result.alreadyAbsent) {
514
+ if (!parsed.values.quiet) stdout.write(`[blueprint] nothing to remove: '${adrId}' is not on manifest.resolutions[].\n`);
515
+ return 0;
516
+ }
517
+ if (!parsed.values.quiet) {
518
+ const topicSuffix = result.topic ? ` (topic '${result.topic}')` : '';
519
+ const idSuffix = result.resolutionId ? `; dropped resolution ${result.resolutionId}` : '';
520
+ stdout.write(`[blueprint] removed resolution for '${adrId}'${topicSuffix}${idSuffix}.\n`);
521
+ }
522
+ return 0;
523
+ }
524
+
525
+ if (verb === 'companions') {
526
+ return handleCompanionsVerb({
527
+ rest,
528
+ parsed,
529
+ projectRoot,
530
+ tree,
531
+ now,
532
+ stdout,
533
+ stderr,
534
+ });
535
+ }
536
+
367
537
  stderr.write(`[error] blueprint: unknown verb '${verb}'\n`);
368
538
  stderr.write(HELP);
369
539
  return 2;
370
540
  }
371
541
 
542
+ /**
543
+ * Parse `--companion <role>=<providerSlug>` occurrences. Slug accepts
544
+ * either `<libraryPrefix>:<slug>` for a library-qualified provider or a
545
+ * bare kebab slug for a shelf provider. Returns { value: [{role,
546
+ * provider}] } on success or { error: string }.
547
+ */
548
+ function parseCompanionOptions(rawList) {
549
+ if (!Array.isArray(rawList) || rawList.length === 0) return { value: [] };
550
+ const out = [];
551
+ for (const raw of rawList) {
552
+ if (typeof raw !== 'string' || raw.length === 0) {
553
+ return { error: `--companion expects <role>=<slug>, got '${raw}'` };
554
+ }
555
+ const eq = raw.indexOf('=');
556
+ if (eq === -1) {
557
+ return { error: `--companion expects <role>=<slug>, got '${raw}' (missing '=')` };
558
+ }
559
+ const role = raw.slice(0, eq);
560
+ const provider = raw.slice(eq + 1);
561
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(role)) {
562
+ return { error: `--companion role '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).` };
563
+ }
564
+ if (provider.length === 0) {
565
+ return { error: `--companion provider slug is empty for role '${role}'` };
566
+ }
567
+ out.push({ role, provider });
568
+ }
569
+ return { value: out };
570
+ }
571
+
572
+ /**
573
+ * Validate a --companion selector: locate the named provider blueprint
574
+ * (library-qualified or shelf), load it, and refuse when its
575
+ * `providesRoles[]` does not contain the requested role. Message shape
576
+ * per spec 5.
577
+ */
578
+ async function validateCompanionProvider({ selector, projectRoot, tree }) {
579
+ const { role, provider } = selector;
580
+ // Applied?
581
+ const applied = (tree?.manifest?.blueprints ?? []).find((bp) => bp.slug === provider);
582
+ if (applied && typeof applied.source === 'string' && !applied.source.includes(':')) {
583
+ const bp = await loadBlueprint(applied.source);
584
+ if (bp.kind) return { error: `--companion ${role}=${provider}: source blueprint failed to load: ${bp.message}` };
585
+ if (Array.isArray(bp.providesRoles) && bp.providesRoles.includes(role)) return { ok: true };
586
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
587
+ }
588
+ // Library-qualified?
589
+ if (provider.includes(':')) {
590
+ const libraries = await enumerateLibraryProviders({ projectRoot, role });
591
+ if (libraries.some((l) => `${l.libraryPrefix}:${l.slug}` === provider)) return { ok: true };
592
+ // Additional diagnostic: check if the library exists but the blueprint does not declare the role
593
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
594
+ }
595
+ // Shelf?
596
+ const shelfProviders = await enumerateShelfProviders(role);
597
+ if (shelfProviders.includes(provider)) return { ok: true };
598
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
599
+ }
600
+
601
+ /**
602
+ * `rcf define blueprint companions <slug>|set|unset` sub-verb
603
+ * dispatcher (spec 2.5). Read-only against the blueprint tree; set /
604
+ * unset only touch rcf/companions.json.
605
+ */
606
+ async function handleCompanionsVerb({ rest, parsed, projectRoot, tree, now, stdout, stderr }) {
607
+ const asJson = parsed.values.json === true;
608
+ const quiet = parsed.values.quiet === true;
609
+ if (rest.length === 0) {
610
+ stderr.write('[error] blueprint companions: missing <slug> or sub-verb (set|unset)\n');
611
+ return 2;
612
+ }
613
+ const head = rest[0];
614
+ // set / unset sub-verbs.
615
+ if (head === 'set') {
616
+ if (rest.length < 3) {
617
+ stderr.write('[error] blueprint companions set: expected <role> <slug>\n');
618
+ return 2;
619
+ }
620
+ const role = rest[1];
621
+ const provider = rest[2];
622
+ const validation = await validateCompanionProvider({ selector: { role, provider }, projectRoot, tree });
623
+ if (validation.error) {
624
+ stderr.write(`[error] blueprint companions set: ${validation.error}\n`);
625
+ return 2;
626
+ }
627
+ const res = await setCompanionPin({ projectRoot, role, provider, now });
628
+ if (res.kind) {
629
+ stderr.write(`[error] blueprint companions set: ${res.message}\n`);
630
+ return 2;
631
+ }
632
+ if (asJson) {
633
+ stdout.write(`${JSON.stringify({ ok: true, role, provider, previousProvider: res.previousProvider, pinnedAt: now.toISOString() })}\n`);
634
+ } else if (!quiet) {
635
+ if (res.previousProvider && res.previousProvider !== provider) {
636
+ stdout.write(`[blueprint] companion pin for role '${role}' updated: '${res.previousProvider}' -> '${provider}'\n`);
637
+ } else {
638
+ stdout.write(`[blueprint] companion pin for role '${role}' set to '${provider}'\n`);
639
+ }
640
+ }
641
+ return 0;
642
+ }
643
+ if (head === 'unset') {
644
+ if (rest.length < 2) {
645
+ stderr.write('[error] blueprint companions unset: expected <role>\n');
646
+ return 2;
647
+ }
648
+ const role = rest[1];
649
+ const res = await unsetCompanionPin({ projectRoot, role });
650
+ if (res.kind) {
651
+ stderr.write(`[error] blueprint companions unset: ${res.message}\n`);
652
+ return 2;
653
+ }
654
+ if (asJson) {
655
+ stdout.write(`${JSON.stringify({ ok: true, role, removed: true })}\n`);
656
+ } else if (!quiet) {
657
+ stdout.write(`[blueprint] companion pin for role '${role}' removed\n`);
658
+ }
659
+ return 0;
660
+ }
661
+ // <slug> form: print the resolved companion set for an applied
662
+ // service blueprint (spec 2.5 first form).
663
+ const slug = head;
664
+ const applied = (tree?.manifest?.blueprints ?? []).find((bp) => bp.slug === slug);
665
+ if (!applied) {
666
+ stderr.write(`[error] blueprint companions: no applied blueprint with slug '${slug}' on this project.\n`);
667
+ return 2;
668
+ }
669
+ if (typeof applied.source !== 'string' || applied.source.length === 0) {
670
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' has no readable source.\n`);
671
+ return 2;
672
+ }
673
+ if (applied.source.includes(':') && !applied.source.startsWith('/')) {
674
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' has a library-qualified source '${applied.source}' the companions reader cannot resolve directly; refresh the library and retry, or supply the source path.\n`);
675
+ return 2;
676
+ }
677
+ const bp = await loadBlueprint(applied.source);
678
+ if (bp.kind) {
679
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' source failed to load: ${bp.message}\n`);
680
+ return 2;
681
+ }
682
+ if (!Array.isArray(bp.suggestedCompanions) || bp.suggestedCompanions.length === 0) {
683
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' declares no suggestedCompanions[].\n`);
684
+ return 2;
685
+ }
686
+ const pins = await readCompanionsFile(projectRoot);
687
+ const pinsClean = pins && pins.kind ? null : pins;
688
+ const resolved = await resolveCompanions({
689
+ projectRoot,
690
+ tree,
691
+ suggestedCompanions: bp.suggestedCompanions,
692
+ pins: pinsClean,
693
+ });
694
+ const ambiguous = resolved.find((r) => r.origin === 'ambiguousLibraries');
695
+ if (ambiguous && !asJson) {
696
+ stderr.write(renderAmbiguousLibraryRefusal({
697
+ role: ambiguous.role,
698
+ providers: ambiguous.ambiguousProviders,
699
+ serviceSlug: slug,
700
+ }));
701
+ return 3;
702
+ }
703
+ if (asJson) {
704
+ stdout.write(`${JSON.stringify({
705
+ slug,
706
+ suggestions: resolved.map((r) => ({
707
+ role: r.role,
708
+ reason: r.reason,
709
+ provider: r.provider,
710
+ origin: r.origin,
711
+ notes: r.notes,
712
+ ...(r.ambiguousProviders ? { ambiguousProviders: r.ambiguousProviders } : {}),
713
+ })),
714
+ }, null, 2)}\n`);
715
+ return ambiguous ? 3 : 0;
716
+ }
717
+ stdout.write(`${slug} suggests companions:\n`);
718
+ stdout.write(`${renderCompanionLines(resolved)}\n`);
719
+ return 0;
720
+ }
721
+
372
722
  /**
373
723
  * Parse `--resolve <topic>=project:<ADR-id>` occurrences into a list
374
724
  * of declarations, attaching an optional shared `reason` (from the
package/src/cli/doctor.js CHANGED
@@ -76,6 +76,7 @@ const KNOWN_CHECKS = /** @type {const} */ ([
76
76
  'browser-present',
77
77
  'playwright-mcp-reachable',
78
78
  'playwright-mcp-redundant',
79
+ 'probe-path-owner',
79
80
  ]);
80
81
 
81
82
  /** The four Playwright-related checks doctor runs conditionally for
@@ -102,7 +103,7 @@ Options:
102
103
  Values: agent-instructions, gitignore,
103
104
  knowledge, identity, playwright-present,
104
105
  browser-present, playwright-mcp-reachable,
105
- playwright-mcp-redundant.
106
+ playwright-mcp-redundant, probe-path-owner.
106
107
  --json Emit machine-readable envelope: { ok, drift, writes, notices }.
107
108
  --quiet Only summary line + first 3 drift items.
108
109
  --force Accept a legacy-markers --fix on hand-edited
@@ -247,6 +248,11 @@ export async function main(argv, deps = {}) {
247
248
  // API-only project even under an explicit --check ask.
248
249
  if (!ctx.browserFacing) continue;
249
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);
250
256
  } else continue;
251
257
  for (const d of result.drift) drift.push({ check, ...d });
252
258
  for (const w of result.writes) writes.push(w);
@@ -705,6 +711,75 @@ async function runPlaywrightMcpRedundantCheck(ctx) {
705
711
  return { drift, writes: [] };
706
712
  }
707
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
+
708
783
  /**
709
784
  * Default reader for the project-root .mcp.json body. Returns the parsed
710
785
  * object, or null on missing / unparseable (doctor treats an unparseable
@@ -6,6 +6,7 @@ import { parseArgs } from 'node:util';
6
6
 
7
7
  import { formatErrors } from '#core/errors';
8
8
  import { checkCodeNodeResolution, walkTree } from '#core/store';
9
+ import { validateCompanionPinsResolvable } from '../blueprint/companions.js';
9
10
  import { findProjectRoot } from '../view/index.js';
10
11
 
11
12
  const OPTION_SPEC = {
@@ -126,6 +127,12 @@ export async function main(argv, deps = {}) {
126
127
  const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
127
128
  errors.push(...staleErrors);
128
129
  }
130
+ // Core-companions spec section 5: rcf/companions.json refusals ride
131
+ // the same exit path as schema / referential-integrity errors. An
132
+ // absent companions.json is not itself an error; a malformed file or
133
+ // a pin that names no known provider is exit 3.
134
+ const companionsErrors = await validateCompanionPinsResolvable({ projectRoot, tree });
135
+ errors.push(...companionsErrors);
129
136
  if (flags.json) {
130
137
  const issues = errors.map((e) => ({
131
138
  id: e.documentId ?? null,