rcf-lite 0.30.0 → 0.31.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 (52) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/define-draft-shapes.md +15 -10
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  6. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  7. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  8. package/rcf/code-nodes/cn-771.json +14 -0
  9. package/rcf/code-nodes/cn-772.json +14 -0
  10. package/rcf/code-nodes/cn-773.json +16 -0
  11. package/rcf/code-nodes/cn-774.json +20 -0
  12. package/rcf/code-nodes/cn-775.json +15 -0
  13. package/rcf/code-nodes/cn-776.json +17 -0
  14. package/rcf/code-nodes/cn-777.json +21 -0
  15. package/rcf/fbs/fbs-202.json +18 -2
  16. package/rcf/fbs/fbs-203.json +12 -4
  17. package/rcf/requirements/req-002.json +2 -2
  18. package/rcf/requirements/req-173.json +2 -2
  19. package/rcf/requirements/req-174.json +2 -2
  20. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  21. package/rcf/test-suites/ts-007.json +1 -1
  22. package/rcf/test-suites/ts-215.json +64 -1
  23. package/rcf/test-suites/ts-216.json +18 -2
  24. package/rcf/test-suites/ts-219.json +10 -2
  25. package/rcf/test-suites/ts-220.json +18 -2
  26. package/rcf/test-suites/ts-225.json +10 -2
  27. package/rcf/test-suites/ts-229.json +49 -3
  28. package/rcf/test-suites/ts-230.json +26 -2
  29. package/rcf/test-suites/ts-231.json +16 -1
  30. package/rcf/test-suites/ts-236.json +44 -0
  31. package/rcf/user-stories/us-17302.json +36 -1
  32. package/rcf/user-stories/us-17401.json +11 -1
  33. package/rcf/user-stories/us-17402.json +6 -1
  34. package/rcf/user-stories/us-17403.json +11 -1
  35. package/rcf/user-stories/us-17404.json +16 -1
  36. package/rcf/user-stories/us-17405.json +7 -2
  37. package/rcf/user-stories/us-205.json +6 -1
  38. package/rcf/user-stories/us-206.json +25 -4
  39. package/rcf/user-stories/us-207.json +36 -0
  40. package/releases/releases.yaml +11 -1
  41. package/src/cli/create.js +22 -2
  42. package/src/cli/ledger.js +96 -4
  43. package/src/cli/readiness.js +15 -1
  44. package/src/core/errors/index.js +7 -1
  45. package/src/core/store/writer.js +181 -13
  46. package/src/define/ledgers.js +86 -7
  47. package/src/mcp/tools.js +1 -1
  48. package/src/query/gates.js +383 -150
  49. package/src/view/html-page.js +14 -2
  50. package/src/view/page-init.js +149 -8
  51. package/src/view/style.css +97 -18
  52. package/src/view/view-index.js +22 -0
@@ -49,6 +49,11 @@
49
49
  "id": "AC-205-8",
50
50
  "description": "[must-not] Given the LookupModal, when the user types a query, picks a result or closes the modal, then no `postMessage` is sent to the parent window, no outbound network request beyond the `./index.json` fetch is made, and no search term is written to `localStorage` / `sessionStorage` / cookies.",
51
51
  "testable": true
52
+ },
53
+ {
54
+ "id": "AC-205-9",
55
+ "description": "[happy] Given the SearchButton (`.rcf-search-btn`) in the shell's `.tools` group, when it is rendered in either theme and in each of its states (default, `:hover`, `:focus-visible`, `:active`), then the computed foreground / background contrast ratio for the button text, the icon stroke against the background and the focus-ring colour against the adjacent surface meets WCAG 2.1 AA (>= 4.5:1 for the text, >= 3:1 for the icon and the focus-ring boundary) in both the light (`:root`) and dark (`:root[data-theme=\"dark\"]`) token sets.",
56
+ "testable": true
52
57
  }
53
58
  ],
54
59
  "tacIds": [
@@ -60,5 +65,5 @@
60
65
  "navigation"
61
66
  ],
62
67
  "createdAt": "2026-10-02T19:00:00Z",
63
- "updatedAt": "2026-10-02T19:00:00Z"
68
+ "updatedAt": "2026-10-04T14:00:00Z"
64
69
  }
@@ -5,10 +5,10 @@
5
5
  "version": "0.1.0",
6
6
  "status": "draft",
7
7
  "title": "wespa's embedding contract is proved by a CI host fixture",
8
- "description": "A same-origin test-host fixture served by the viewer's dev server (route `/test-host.html` behind a `--test-host` flag or an env guard; off by default so wespa's proxy allow-list is unaffected) mimics the wespa shell: a 56px header, a resizable sidebar (64px collapsed, 200-400px expanded, user-resizable live via a drag handle), a content pane carrying an `<iframe src=\"./?embed=1&theme=light\">`. The host posts `{ type: \"rcf-view-theme\", theme: \"light\"|\"dark\" }` on a toggle control; it stores no theme itself so a passing run proves the iframe reads the message. The viewer sends no outbound postMessage. Three lints run in the test suite: (1) framing lint (`GET /` returns no `X-Frame-Options`, no CSP `frame-ancestors` that forbids same-origin, and the fixture loads the iframe without a browser error); (2) target lint (no `target=\"_top\"` string in the server-rendered HTML); (3) localStorage namespace lint (every `localStorage.{setItem,getItem,removeItem}` and shared `storage.*` call site in `src/view/**` uses a key starting with `rcf-view:v1:`). See viewer UI refresh design doc section 9 and Dex's wespa requirements of 2026-10-02.",
8
+ "description": "A same-origin test-host fixture served by the viewer's dev server (route `/test-host.html` behind a `--test-host` flag or an env guard; off by default so wespa's proxy allow-list is unaffected) mimics the wespa shell: a 56px header, a resizable sidebar (64px collapsed, 200-400px expanded, user-resizable live via a drag handle), a content pane carrying an `<iframe src=\"./?embed=1&theme=light\">`. The host posts `{ type: \"rcf-view-theme\", theme: \"light\"|\"dark\" }` on a toggle control; it stores no theme itself so a passing run proves the iframe reads the message. The viewer sends no outbound postMessage. Three lints run in the test suite: (1) framing lint (`GET /` returns no `X-Frame-Options`, no CSP `frame-ancestors` that forbids same-origin, and the fixture loads the iframe without a browser error); (2) target lint (no `target=\"_top\"` string in the server-rendered HTML); (3) localStorage namespace lint (every `localStorage.{setItem,getItem,removeItem}` and shared `storage.*` call site in `src/view/**` uses a key starting with `rcf-view:v1:`). Amended 2026-10-04 (w-2026-10-04-dave-001): the viewer's own three-state theme control (Light|Dark|Auto) sits right of the SearchButton in non-embed mode, is hidden entirely under `embed=1` and in non-embed mode persists its setting under `rcf-view:v1:theme` per ADR-4137 (Dave's storage ruling 2026-10-04). See viewer UI refresh design doc section 9 and section 11 recommendation 6, and Dex's wespa requirements of 2026-10-02.",
9
9
  "asA": "Dex integrating the rcf-lite viewer behind wespa's reverse proxy",
10
- "iWant": "a CI host fixture that proves the viewer honours the embedding contract and three lints that keep framing, target='_top' and localStorage namespace in place over time",
11
- "soThat": "regressions in the embedding contract are caught in the viewer's own suite before they reach wespa",
10
+ "iWant": "a CI host fixture that proves the viewer honours the embedding contract and three lints that keep framing, target='_top' and localStorage namespace in place over time; and a visible three-state theme control in the standalone viewer that is hidden under embed=1",
11
+ "soThat": "regressions in the embedding contract are caught in the viewer's own suite before they reach wespa, and standalone viewer users can switch theme without a URL edit",
12
12
  "acceptanceCriteria": [
13
13
  {
14
14
  "id": "AC-206-1",
@@ -39,6 +39,26 @@
39
39
  "id": "AC-206-6",
40
40
  "description": "[must-not] Given the test-host fixture and the viewer page, when either is loaded, then neither posts any outbound message beyond the host's `rcf-view-theme`; the viewer sends no `postMessage` of any shape, and the test-host adds no new postMessage shape.",
41
41
  "testable": true
42
+ },
43
+ {
44
+ "id": "AC-206-7",
45
+ "description": "[happy] Given the standalone viewer loaded without `embed=1`, when the shell paints, then a three-state theme control (`role=\"group\"`, three buttons Light|Dark|Auto with `aria-pressed`) renders in the header's `.tools` group right of the SearchButton; when a button is pressed, `<html data-theme>` is set to `light` or `dark` (or the attribute is removed for Auto so the `prefers-color-scheme` media query decides) live without a reload, and the pressed button's `aria-pressed` becomes `true` while the others become `false`.",
46
+ "testable": true
47
+ },
48
+ {
49
+ "id": "AC-206-8",
50
+ "description": "[happy] Given the standalone (non-embed) viewer, when a theme button is pressed, then the chosen value (`light`, `dark` or `auto`) is written to `localStorage['rcf-view:v1:theme']`; on the next page load the stored value is reapplied; the boot precedence is `?theme=` wins first, then the stored value, then the default (`auto`); and the postMessage `rcf-view-theme` listener still applies live but writes nothing to the stored key.",
51
+ "testable": true
52
+ },
53
+ {
54
+ "id": "AC-206-9",
55
+ "description": "[must-not] Given the viewer loaded with `?embed=1`, when the shell paints, then the three-state theme control is absent from the DOM entirely (no `[data-rcf-theme-control]` element rendered, not merely hidden by CSS), and no read or write of `localStorage['rcf-view:v1:theme']` happens on load, on a postMessage theme change, or on any user action inside the viewer.",
56
+ "testable": true
57
+ },
58
+ {
59
+ "id": "AC-206-10",
60
+ "description": "[happy] Given the standalone viewer with a stored value `rcf-view:v1:theme = dark`, when the URL carries `?theme=light`, then on load `<html data-theme>` is `light` (the explicit query wins) and the Light button is pressed; the stored value is left as `dark` and is reused on the next load without a `?theme=` query.",
61
+ "testable": true
42
62
  }
43
63
  ],
44
64
  "tacIds": [
@@ -47,8 +67,9 @@
47
67
  "tags": [
48
68
  "view",
49
69
  "embedding",
70
+ "theme",
50
71
  "lint"
51
72
  ],
52
73
  "createdAt": "2026-10-02T20:00:00Z",
53
- "updatedAt": "2026-10-02T20:00:00Z"
74
+ "updatedAt": "2026-10-04T14:00:00Z"
54
75
  }
@@ -0,0 +1,36 @@
1
+ {
2
+ "usId": "US-207",
3
+ "prdId": "PRD-001",
4
+ "reqId": "REQ-002",
5
+ "version": "0.1.0",
6
+ "status": "draft",
7
+ "title": "Every viewer text and control colour pair meets WCAG 2.1 AA in both themes",
8
+ "description": "A static CSS-contrast audit test parses `packages/rcf-lite/src/view/style.css`, resolves the `--sv-*` custom properties for the light (`:root`) and dark (`:root[data-theme=\"dark\"]`) token blocks, and enumerates every rule that sets `color` together with a reachable `background` / `background-color` (own rule, then the nearest ancestor surface token, default `--sv-canvas` page background). Each pair is enumerated in both themes and across every reachable state variant: default, `:hover`, `:focus`, `:focus-visible`, `:active`, `[aria-pressed=\"true\"]`, `[aria-selected=\"true\"]`, `.is-active`, `:disabled`. For each pair the WCAG relative-luminance contrast ratio is computed and asserted against the WCAG 2.1 AA thresholds (>= 4.5:1 for normal text, >= 3:1 for large text and non-text UI boundaries per WCAG 1.4.3 and 1.4.11). Pairs that cannot be resolved statically (gradients, rgba over an unknown background) are computed against the two most likely surfaces (`--sv-canvas` and the ancestor surface token) and recorded as approximate rather than skipped. The test emits a BEFORE/AFTER table under `./output/contrast-report.md` and refuses the suite on any failing pair. Scope covers the whole viewer shell, every tab, components, SearchButton and LookupModal, Readiness, Requirements, Architecture, Build (Specs and DAG), ID lookup; Mermaid and Product Map themed palettes are audited on a best-effort basis and anything bound to a third-party default is listed as a known gap with its ratio. See viewer UI refresh design doc section 9 (theme) and Baz's 2026-10-04 review (`w-2026-10-04-dave-001`).",
9
+ "asA": "an engineer or reviewer looking at the review surface in either theme",
10
+ "iWant": "every text and control colour pair in the viewer to meet WCAG 2.1 AA in both themes, pinned by a test that fails the suite when a new rule breaks the floor",
11
+ "soThat": "the review surface stays readable in both themes and nobody has to re-do the audit by hand on every CSS change",
12
+ "acceptanceCriteria": [
13
+ {
14
+ "id": "AC-207-1",
15
+ "description": "[happy] Given the viewer stylesheet `src/view/style.css` and the two token blocks (`:root` and `:root[data-theme=\"dark\"]`), when the contrast test runs, then it enumerates every CSS rule that sets `color` (and every state rule merged over its base selector, so a state that changes only the background is paired with the inherited colour) paired with every reachable background (own rule, nearest ancestor surface token, page background `--sv-canvas`) and every state variant (default, `:hover`, `:focus`, `:focus-visible`, `:active`, `[aria-pressed=\"true\"]`, `[aria-selected=\"true\"]`, `.is-active`, `:disabled`), computes the WCAG relative-luminance contrast ratio for each, and asserts >= 4.5:1 for text, >= 3:1 for large text (>= 18.66px bold or >= 24px) and for non-text UI boundaries (control borders and outlines, focus rings against the adjacent colour, each enumerated as its own pair) per WCAG 1.4.3 and 1.4.11 in both the light and dark token sets.",
16
+ "testable": true
17
+ },
18
+ {
19
+ "id": "AC-207-2",
20
+ "description": "[failure] Given a contrast pair that falls below the applicable threshold, when the test runs, then the failure message names the selector, state, theme, foreground colour, background colour, computed ratio and the threshold applied, and the test exits non-zero so the suite blocks a merge that drops a pair below AA.",
21
+ "testable": true
22
+ }
23
+ ],
24
+ "tacIds": [
25
+ "TAC-4134-wespa-host-fixture"
26
+ ],
27
+ "tags": [
28
+ "view",
29
+ "contrast",
30
+ "accessibility",
31
+ "a11y",
32
+ "theme"
33
+ ],
34
+ "createdAt": "2026-10-04T14:00:00Z",
35
+ "updatedAt": "2026-10-05T12:30:00Z"
36
+ }
@@ -40,8 +40,18 @@
40
40
  # `npm install rcf-lite`.
41
41
 
42
42
  feedVersion: 1
43
- latest: "0.30.0"
43
+ latest: "0.31.0"
44
44
  releases:
45
+ - version: "0.31.0"
46
+ date: "2026-10-05"
47
+ breaking: true
48
+ headlines:
49
+ - "rcf-lite 0.31.0 gives the viewer a Light, Dark and Auto theme control in standalone mode and brings its text and control colours up to WCAG AA contrast in both themes, with a test that blocks any regression. Under embed=1 the control is absent, nothing is stored and the rcf-view-theme message contract is unchanged."
50
+ - "DEFINE step 3 rulings R1 to R13 land: tree-wide readiness checks now run before any scope short-circuit, so a narrowed scope still reports a draft marker, an unjoined core entity, an uncited standards pack, a missing Deploy ADR or an empty security or operational section anywhere in the tree. A stage whose scope is empty but whose tree-wide check fails now reads failing rather than not applicable, and the draft-settled check is tree-wide."
51
+ - "Write-backs that carry an id are idempotent: rcf define create <kind> --id and rcf define ledger <name> add --id exit 0 and write nothing when the same id is replayed with the same content, and exit 3 naming the differing fields when the content differs."
52
+ minAgentAction: null
53
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
54
+
45
55
  - version: "0.30.0"
46
56
  date: "2026-10-03"
47
57
  breaking: true
package/src/cli/create.js CHANGED
@@ -64,8 +64,11 @@ Options:
64
64
  every non-root child carries a mandatory
65
65
  parentId-style field). See the parent table
66
66
  above for which id each kind expects.
67
- --id <id> Override auto-assigned id (refuses on
68
- collision)
67
+ --id <id> Override auto-assigned id. Idempotent by id:
68
+ an existing id with identical content is a
69
+ no-op ('<id>: unchanged', exit 0); with
70
+ different content it refuses (exit 3, names
71
+ the differing fields). Use update to patch
69
72
  --title <string> Required for req / us / tac / adr / fbs / ts
70
73
  (ac / tc use --description)
71
74
  --description <string> Body description; required for ac / tc
@@ -297,6 +300,15 @@ export async function main(argv, deps = {}) {
297
300
  if (isRcfError(result)) {
298
301
  return handleWriterError(result, stderr);
299
302
  }
303
+ // Ruling R1 extension 2026-10-05 (PR 292 follow-up, w-2026-10-05-dave-013):
304
+ // a same-id replay with identical content exits 0 with 'unchanged';
305
+ // the file is not rewritten. Mirrors `rcf define ledger add --id`.
306
+ if (result.status === 'unchanged') {
307
+ if (!flags.quiet) {
308
+ stdout.write(`${result.id}: unchanged (${result.filePath})\n`);
309
+ }
310
+ return 0;
311
+ }
300
312
  if (options.dryRun) {
301
313
  if (!flags.quiet) stdout.write(`[dry-run] would create ${result.id} at ${result.filePath}\n`);
302
314
  return 0;
@@ -358,6 +370,10 @@ const ERROR_KINDS = new Set([
358
370
  'parseFailure',
359
371
  'ioFailure',
360
372
  'usage',
373
+ // Ruling R1 extension 2026-10-05 (PR 292 follow-up): a same-id
374
+ // create whose would-be body differs from the existing doc refuses
375
+ // with `kind: 'conflict'` and exits 3 (never silently overwrites).
376
+ 'conflict',
361
377
  ]);
362
378
 
363
379
  function isRcfError(value) {
@@ -412,6 +428,10 @@ function handleWriterError(err, stderr) {
412
428
  stderr.write(`[error] ${kind} ${err.message}\n`);
413
429
  if (kind === 'usage') return 2;
414
430
  if (kind === 'validation' || kind === 'brokenReference') return 3;
431
+ // Ruling R1 extension 2026-10-05 (PR 292 follow-up): a same-id
432
+ // create with different content refuses with exit 3; `update` is
433
+ // the verb for patching.
434
+ if (kind === 'conflict') return 3;
415
435
  if (kind === 'missingFile' || kind === 'parseFailure') return 2;
416
436
  return 1;
417
437
  }
package/src/cli/ledger.js CHANGED
@@ -27,6 +27,7 @@ import { runIntakeScansOnDelta } from '../intake/orchestrator.js';
27
27
  import { findProjectRoot } from '../view/index.js';
28
28
 
29
29
  const OPTION_SPEC = {
30
+ id: { type: 'string' },
30
31
  kind: { type: 'string' },
31
32
  text: { type: 'string' },
32
33
  from: { type: 'string' },
@@ -74,6 +75,15 @@ Common flags:
74
75
  --help Print this help
75
76
 
76
77
  brief add flags:
78
+ --id <n> Optional positive integer. When set, the add
79
+ is idempotent by id (ruling R1 2026-10-05):
80
+ an entry at that id with identical content
81
+ exits 0 printing 'unchanged' (--json emits
82
+ {status:"unchanged",id:n}); different content
83
+ at the same id exits 3 naming the differing
84
+ fields; a free id is honoured as the override.
85
+ The harness carries --id back when it replays
86
+ a turn so the add is a no-op on replay.
77
87
  --kind <${BRIEF_KINDS.join('|')}>
78
88
  Statement kind (default: capability)
79
89
  --text <text> The statement text (mutually exclusive with --from)
@@ -193,6 +203,14 @@ export async function main(argv, deps = {}) {
193
203
  return await runResolve({ projectRoot, name, positionals, flags, stdout, stderr });
194
204
  } catch (err) {
195
205
  if (err instanceof LedgerError) {
206
+ // Ruling R1 2026-10-05: a same-id-different-content refusal
207
+ // exits 3 (not 1 and not 2) so a replaying harness can
208
+ // distinguish 'you tried to overwrite a prior answer' from a
209
+ // schema failure or a usage error. Update is the verb for that.
210
+ if (err.code === 'conflict') {
211
+ stderr.write(`[error] ledger: ${err.message}\n`);
212
+ return 3;
213
+ }
196
214
  stderr.write(`[error] ${err.code === 'usage' ? 'usage ' : ''}ledger: ${err.message}\n`);
197
215
  return err.code === 'usage' ? 2 : 1;
198
216
  }
@@ -285,6 +303,19 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
285
303
  const body = await loadLedger({ projectRoot, name });
286
304
  const now = new Date().toISOString();
287
305
 
306
+ // Ruling R1 2026-10-05: parse --id up front; a non-integer or negative
307
+ // value is a usage error BEFORE loading / scanning the ledger.
308
+ /** @type {number | undefined} */
309
+ let idOverride;
310
+ if (flags.id !== undefined) {
311
+ const parsed = Number(flags.id);
312
+ if (!Number.isInteger(parsed) || parsed < 1) {
313
+ stderr.write(`[error] usage ledger: --id must be a positive integer, got '${flags.id}'\n`);
314
+ return 2;
315
+ }
316
+ idOverride = parsed;
317
+ }
318
+
288
319
  if (name === 'brief') {
289
320
  const kind = /** @type {string} */ (flags.kind ?? 'capability');
290
321
  if (!BRIEF_KINDS.includes(/** @type {any} */ (kind))) {
@@ -299,6 +330,12 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
299
330
  stderr.write('[error] usage ledger: brief add requires --text <text> or --from <path>\n');
300
331
  return 2;
301
332
  }
333
+ if (idOverride !== undefined && flags.from) {
334
+ // R1 identity is per entry; --from mints N entries, so --id has no
335
+ // single target. Refuse the combination to keep the harness honest.
336
+ stderr.write('[error] usage ledger: brief add --id applies to --text only (--from mints one entry per line and has no single id)\n');
337
+ return 2;
338
+ }
302
339
  if (flags['no-scan'] && flags.findings) {
303
340
  stderr.write('[error] usage ledger: brief add --no-scan and --findings cannot be combined\n');
304
341
  return 2;
@@ -336,10 +373,32 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
336
373
  // Compute what WOULD be minted (for both the real path and dry-run).
337
374
  let cur = body;
338
375
  const added = [];
376
+ /** @type {'added' | 'unchanged'} */
377
+ let primaryStatus = 'added';
339
378
  for (const d of drafts) {
340
- const step = addEntry({ name: 'brief', body: cur, entry: d, now });
379
+ // Only the single --text path reaches here with an idOverride (we
380
+ // refused --id with --from above); a per-call idOverride is passed
381
+ // on the first and only draft.
382
+ const step = addEntry({
383
+ name: 'brief', body: cur, entry: d, now,
384
+ ...(idOverride !== undefined ? { id: idOverride } : {}),
385
+ });
341
386
  cur = step.body;
342
387
  added.push(step.entry);
388
+ if (step.status === 'unchanged') primaryStatus = 'unchanged';
389
+ }
390
+
391
+ // Ruling R1 2026-10-05 short-circuit: when --id landed on an entry
392
+ // that already carried identical content, exit 0 with the unchanged
393
+ // signal before any scan / finding work. A replayed turn is a no-op.
394
+ if (primaryStatus === 'unchanged' && added.length === 1) {
395
+ const existing = added[0];
396
+ if (flags.json) {
397
+ stdout.write(`${JSON.stringify({ status: 'unchanged', id: existing.id, entry: existing }, null, 2)}\n`);
398
+ } else {
399
+ stdout.write(`brief-ledger: unchanged ${existing.id}\n`);
400
+ }
401
+ return 0;
343
402
  }
344
403
 
345
404
  // Convert operator-supplied --findings into openQuestion statements.
@@ -428,7 +487,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
428
487
  default: flags.default ? String(flags.default) : null,
429
488
  };
430
489
  if (flags.blocks) entry.blocks = String(flags.blocks);
431
- const step = addEntry({ name: 'decisions', body, entry, now });
490
+ const step = addEntry({
491
+ name: 'decisions', body, entry, now,
492
+ ...(idOverride !== undefined ? { id: idOverride } : {}),
493
+ });
494
+ if (step.status === 'unchanged') {
495
+ if (flags.json) {
496
+ stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
497
+ } else {
498
+ stdout.write(`decisions-ledger: unchanged ${step.entry.id}\n`);
499
+ }
500
+ return 0;
501
+ }
432
502
  await saveLedger({ projectRoot, name: 'decisions', body: step.body });
433
503
  stdout.write(`decisions-ledger: added decision ${step.entry.id}\n`);
434
504
  return 0;
@@ -449,7 +519,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
449
519
  disposition: String(flags.disposition),
450
520
  };
451
521
  if (flags.reason) entry.reason = String(flags.reason);
452
- const step = addEntry({ name: 'concerns', body, entry, now });
522
+ const step = addEntry({
523
+ name: 'concerns', body, entry, now,
524
+ ...(idOverride !== undefined ? { id: idOverride } : {}),
525
+ });
526
+ if (step.status === 'unchanged') {
527
+ if (flags.json) {
528
+ stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
529
+ } else {
530
+ stdout.write(`concern-ledger: unchanged ${step.entry.id}\n`);
531
+ }
532
+ return 0;
533
+ }
453
534
  await saveLedger({ projectRoot, name: 'concerns', body: step.body });
454
535
  stdout.write(`concern-ledger: added concern ${step.entry.id}\n`);
455
536
  return 0;
@@ -465,7 +546,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
465
546
  finding: String(flags.finding),
466
547
  severity: flags.severity ? String(flags.severity) : 'medium',
467
548
  };
468
- const step = addEntry({ name: 'probes', body, entry, now });
549
+ const step = addEntry({
550
+ name: 'probes', body, entry, now,
551
+ ...(idOverride !== undefined ? { id: idOverride } : {}),
552
+ });
553
+ if (step.status === 'unchanged') {
554
+ if (flags.json) {
555
+ stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
556
+ } else {
557
+ stdout.write(`probe-ledger: unchanged ${step.entry.id}\n`);
558
+ }
559
+ return 0;
560
+ }
469
561
  await saveLedger({ projectRoot, name: 'probes', body: step.body });
470
562
  stdout.write(`probe-ledger: added probe ${step.entry.id}\n`);
471
563
  return 0;
@@ -390,7 +390,7 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
390
390
 
391
391
  for (const stage of result.stages) {
392
392
  if (checkStage && checkStage !== 'all' && stage.stage !== checkStage) continue;
393
- stdout.write(`\n${stage.stage} (${stage.gate}): ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}\n`);
393
+ stdout.write(`\n${formatStageLine(stage)}\n`);
394
394
  for (const c of stage.checks) {
395
395
  if (persona && c.persona !== persona) continue;
396
396
  const mark = c.ok ? 'ok' : 'FAIL';
@@ -405,6 +405,20 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
405
405
  }
406
406
  }
407
407
 
408
+ /**
409
+ * Format the per-stage header line the text report prints, e.g.
410
+ * `D3 (define.shapes): failing`. A `reason` trailer is appended only
411
+ * when the envelope carries one (the `notApplicable` envelopes do; the
412
+ * ADR-4138 tree-wide failing envelope does not). Exported so the
413
+ * printed line is testable without driving the whole CLI.
414
+ *
415
+ * @param {{ stage: string, gate: string, state: string, reason?: string }} stage
416
+ * @returns {string}
417
+ */
418
+ export function formatStageLine(stage) {
419
+ return `${stage.stage} (${stage.gate}): ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}`;
420
+ }
421
+
408
422
  /**
409
423
  * Render the per-persona next-action lines (spec section 4). When a
410
424
  * `--level` is given the matching persona's line heads the pair; when
@@ -7,7 +7,7 @@
7
7
  // renders them inline in the page so an owner can see what is broken.
8
8
 
9
9
  /**
10
- * @typedef {('validation'|'missingFile'|'brokenReference'|'parseFailure'|'ioFailure'|'usage'|'staleCode'|'missingCodeNodes'|'duplicateId')} ErrorKind
10
+ * @typedef {('validation'|'missingFile'|'brokenReference'|'parseFailure'|'ioFailure'|'usage'|'staleCode'|'missingCodeNodes'|'duplicateId'|'conflict')} ErrorKind
11
11
  */
12
12
 
13
13
  /**
@@ -49,6 +49,12 @@ const VALID_KINDS = new Set([
49
49
  // with the spec-verbatim refusal message; the CLI translates it to
50
50
  // exit 3 (not exit 2) with the message printed as-is on stderr.
51
51
  'requiresAppliedCapabilities',
52
+ // Ruling R1 extension 2026-10-05 (DEFINE step 3, PR 292 follow-up):
53
+ // a same-id `rcf define create` replay whose would-be body differs
54
+ // from the on-disk entry refuses with `kind: 'conflict'`; the CLI
55
+ // translates it to exit 3 with the id and the differing field keys
56
+ // named in the message. Mirrors `src/define/ledgers.js` R1 add path.
57
+ 'conflict',
52
58
  ]);
53
59
 
54
60
  /**