rcf-lite 0.30.0 → 0.32.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 (65) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12113.json +1 -1
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +15 -10
  5. package/package.json +1 -1
  6. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  7. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  8. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  9. package/rcf/code-nodes/cn-017.json +4 -2
  10. package/rcf/code-nodes/cn-771.json +15 -0
  11. package/rcf/code-nodes/cn-772.json +14 -0
  12. package/rcf/code-nodes/cn-773.json +16 -0
  13. package/rcf/code-nodes/cn-774.json +20 -0
  14. package/rcf/code-nodes/cn-775.json +15 -0
  15. package/rcf/code-nodes/cn-776.json +17 -0
  16. package/rcf/code-nodes/cn-777.json +21 -0
  17. package/rcf/code-nodes/cn-778.json +14 -0
  18. package/rcf/fbs/fbs-004.json +4 -1
  19. package/rcf/fbs/fbs-201.json +2 -1
  20. package/rcf/fbs/fbs-202.json +18 -2
  21. package/rcf/fbs/fbs-203.json +12 -4
  22. package/rcf/requirements/req-002.json +2 -2
  23. package/rcf/requirements/req-173.json +2 -2
  24. package/rcf/requirements/req-174.json +2 -2
  25. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  26. package/rcf/tacs/tac-4127-readiness-view.json +3 -2
  27. package/rcf/test-suites/ts-004.json +82 -2
  28. package/rcf/test-suites/ts-007.json +1 -1
  29. package/rcf/test-suites/ts-215.json +64 -1
  30. package/rcf/test-suites/ts-216.json +18 -2
  31. package/rcf/test-suites/ts-219.json +10 -2
  32. package/rcf/test-suites/ts-220.json +18 -2
  33. package/rcf/test-suites/ts-223.json +24 -2
  34. package/rcf/test-suites/ts-225.json +10 -2
  35. package/rcf/test-suites/ts-229.json +49 -3
  36. package/rcf/test-suites/ts-230.json +26 -2
  37. package/rcf/test-suites/ts-231.json +16 -1
  38. package/rcf/test-suites/ts-236.json +44 -0
  39. package/rcf/user-stories/us-17302.json +36 -1
  40. package/rcf/user-stories/us-17401.json +11 -1
  41. package/rcf/user-stories/us-17402.json +6 -1
  42. package/rcf/user-stories/us-17403.json +11 -1
  43. package/rcf/user-stories/us-17404.json +16 -1
  44. package/rcf/user-stories/us-17405.json +7 -2
  45. package/rcf/user-stories/us-18001.json +6 -1
  46. package/rcf/user-stories/us-202.json +25 -1
  47. package/rcf/user-stories/us-205.json +6 -1
  48. package/rcf/user-stories/us-206.json +25 -4
  49. package/rcf/user-stories/us-207.json +36 -0
  50. package/releases/releases.yaml +20 -1
  51. package/src/cli/create.js +22 -2
  52. package/src/cli/freeze.js +8 -0
  53. package/src/cli/ledger.js +96 -4
  54. package/src/cli/readiness.js +17 -39
  55. package/src/core/errors/index.js +7 -1
  56. package/src/core/store/writer.js +181 -13
  57. package/src/define/interface-paths.js +47 -0
  58. package/src/define/ledgers.js +86 -7
  59. package/src/mcp/tools.js +1 -1
  60. package/src/query/gates.js +431 -163
  61. package/src/view/html-page.js +43 -13
  62. package/src/view/live-client.js +18 -10
  63. package/src/view/page-init.js +149 -8
  64. package/src/view/style.css +107 -20
  65. package/src/view/view-index.js +22 -0
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/freeze.js CHANGED
@@ -35,6 +35,7 @@ import { checkCodeNodeResolution } from '#core/store';
35
35
  import { findProjectRoot } from '../view/index.js';
36
36
  import { loadFreezeRecord, saveFreezeRecord } from '../define/freeze-record.js';
37
37
  import { loadAllLedgers } from '../define/ledgers.js';
38
+ import { resolveInterfacePaths } from '../define/interface-paths.js';
38
39
  import {
39
40
  STAGE_ALIASES,
40
41
  STAGE_GATES,
@@ -368,6 +369,12 @@ async function runFreeze({
368
369
  // freezeableAfterAck check below is the real gate (freeze still
369
370
  // exits 4 on an unfrozen tree); the wrap's refusal is informational
370
371
  // here as it is on readiness.
372
+ // Issue 302 (2026-10-06): freeze now pre-resolves the TAC
373
+ // interface `path:` tokens the way readiness does, so the D3
374
+ // `shapes:pathsResolve` check inside freeze agrees with the
375
+ // readiness verdict instead of defaulting to an empty set and
376
+ // false-failing resolvable paths.
377
+ const resolvedPaths = await resolveInterfacePaths(projectRoot, tree);
371
378
  /** @type {import('../query/readiness.js').ReadinessResult} */
372
379
  const readiness = computeReadiness(tree, {
373
380
  freeze: freezeForCompute,
@@ -375,6 +382,7 @@ async function runFreeze({
375
382
  profileText,
376
383
  testPointers,
377
384
  validateErrors,
385
+ resolvedPaths,
378
386
  });
379
387
  const gated = await runWithAdmissibilityGate({
380
388
  tree,
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;
@@ -16,7 +16,7 @@
16
16
  // every other query verb has.
17
17
 
18
18
  import { parseArgs } from 'node:util';
19
- import { readFile, stat } from 'node:fs/promises';
19
+ import { readFile } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
21
 
22
22
  import { resolveTestPointers, walkTree } from '#core/store';
@@ -30,9 +30,9 @@ import {
30
30
  STAGE_ORDER,
31
31
  STAGE_SHORT_NAMES,
32
32
  ackable,
33
- extractInterfacePathTokens,
34
33
  stagePolicy,
35
34
  } from '../query/gates.js';
35
+ import { resolveInterfacePaths } from '../define/interface-paths.js';
36
36
  import {
37
37
  computeReadiness,
38
38
  countLitmusReadersAtHash,
@@ -129,42 +129,6 @@ const STATE_LABEL = {
129
129
  notApplicable: 'notApplicable',
130
130
  };
131
131
 
132
- /**
133
- * ADR-4131 (0.30.0 PR 5): walk the tree's TAC interfaces, extract the
134
- * `path:` tokens the engineer named, resolve each one against
135
- * `projectRoot` on disk, and return the set of tokens that resolve.
136
- * `shapes:pathsResolve` reads the set from the stage context and
137
- * decides the D3 bite without I/O. The compute is pure; this helper is
138
- * the one place I/O happens for the check.
139
- *
140
- * @param {string} projectRoot
141
- * @param {import('#core/store/walker.js').TreeModel} tree
142
- * @returns {Promise<Set<string>>}
143
- */
144
- async function resolveInterfacePaths(projectRoot, tree) {
145
- const resolved = new Set();
146
- /** @type {Set<string>} */
147
- const candidates = new Set();
148
- for (const tac of tree.tacs ?? []) {
149
- for (const iface of tac.interfaces ?? []) {
150
- const desc = typeof iface?.description === 'string' ? iface.description : '';
151
- for (const token of extractInterfacePathTokens(desc)) {
152
- candidates.add(token);
153
- }
154
- }
155
- }
156
- await Promise.all([...candidates].map(async (token) => {
157
- try {
158
- await stat(join(projectRoot, token));
159
- resolved.add(token);
160
- } catch {
161
- // Missing path stays out of the set; the gate decides whether
162
- // `authoredAt: D3` lets it through.
163
- }
164
- }));
165
- return resolved;
166
- }
167
-
168
132
  /**
169
133
  * Read `rcf/.identity/profile.md` when it exists; return null on
170
134
  * ENOENT. Any other read error surfaces as null so the profile
@@ -390,7 +354,7 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
390
354
 
391
355
  for (const stage of result.stages) {
392
356
  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`);
357
+ stdout.write(`\n${formatStageLine(stage)}\n`);
394
358
  for (const c of stage.checks) {
395
359
  if (persona && c.persona !== persona) continue;
396
360
  const mark = c.ok ? 'ok' : 'FAIL';
@@ -405,6 +369,20 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
405
369
  }
406
370
  }
407
371
 
372
+ /**
373
+ * Format the per-stage header line the text report prints, e.g.
374
+ * `D3 (define.shapes): failing`. A `reason` trailer is appended only
375
+ * when the envelope carries one (the `notApplicable` envelopes do; the
376
+ * ADR-4138 tree-wide failing envelope does not). Exported so the
377
+ * printed line is testable without driving the whole CLI.
378
+ *
379
+ * @param {{ stage: string, gate: string, state: string, reason?: string }} stage
380
+ * @returns {string}
381
+ */
382
+ export function formatStageLine(stage) {
383
+ return `${stage.stage} (${stage.gate}): ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}`;
384
+ }
385
+
408
386
  /**
409
387
  * Render the per-persona next-action lines (spec section 4). When a
410
388
  * `--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
  /**
@@ -52,6 +52,111 @@ function canonicalKind(kind) {
52
52
  return KIND_ALIASES[kind] ?? kind;
53
53
  }
54
54
 
55
+ /**
56
+ * Fields the R1 create-replay compare ignores: every field the writer or
57
+ * a post-create side effect stamps on its own, never from the caller's
58
+ * input. A verbatim replay of a create must compare equal even after
59
+ * those stamps landed; every other field is deep-compared. Add a field
60
+ * here whenever a writer path or a post-create hook starts stamping one.
61
+ * Ruling R1 extension (PR 292 follow-up; list made exhaustive on the
62
+ * PR 293 landing review, d-2026-10-05-015 finding 2).
63
+ */
64
+ export const REPLAY_IGNORE_FIELDS = Object.freeze([
65
+ // Writer clock: assembleBody / createCn stamp it on every create and
66
+ // strip any caller-supplied value (B1 fix).
67
+ 'createdAt',
68
+ // Writer clock: stamped on every create and bumped by every update,
69
+ // including the classifier's own write-back below.
70
+ 'updatedAt',
71
+ // REQ shape classifier (src/req-detection/persist.js
72
+ // classifyAndPersistReq): src/cli/create.js runs it after every
73
+ // `create req` and persists the block through updateDocument, so the
74
+ // first create leaves a field the replay body never carries.
75
+ 'shapeClassification',
76
+ ]);
77
+ const REPLAY_IGNORE_SET = new Set(REPLAY_IGNORE_FIELDS);
78
+
79
+ /**
80
+ * Deep-equality over JSON-shaped values. Pure. Mirrors
81
+ * `src/define/ledgers.js` `deepEqual` so the R1 extension to `rcf
82
+ * define create` compares content the same way the add path does.
83
+ */
84
+ function deepEqual(a, b) {
85
+ if (Object.is(a, b)) return true;
86
+ if (a === null || b === null) return false;
87
+ if (typeof a !== typeof b) return false;
88
+ if (Array.isArray(a)) {
89
+ if (!Array.isArray(b) || a.length !== b.length) return false;
90
+ for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
91
+ return true;
92
+ }
93
+ if (typeof a === 'object') {
94
+ if (typeof b !== 'object' || Array.isArray(b)) return false;
95
+ const ak = Object.keys(a);
96
+ const bk = Object.keys(b);
97
+ if (ak.length !== bk.length) return false;
98
+ for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Compare an on-disk doc body to a would-be create body and return
106
+ * the sorted list of differing field keys. Ignores every field in
107
+ * REPLAY_IGNORE_FIELDS (writer- and classifier-owned stamps), so a
108
+ * verbatim replay does not show up as "updatedAt differs" or
109
+ * "shapeClassification differs". A key present on only one side counts as
110
+ * differing. R1 extension 2026-10-05 (PR 292 follow-up).
111
+ *
112
+ * @param {Record<string, unknown>} existing
113
+ * @param {Record<string, unknown>} candidate
114
+ * @returns {string[]}
115
+ */
116
+ function diffDocFields(existing, candidate) {
117
+ /** @type {Set<string>} */
118
+ const differing = new Set();
119
+ const keys = new Set([...Object.keys(existing ?? {}), ...Object.keys(candidate ?? {})]);
120
+ for (const key of keys) {
121
+ if (REPLAY_IGNORE_SET.has(key)) continue;
122
+ if (!deepEqual(existing?.[key], candidate?.[key])) differing.add(key);
123
+ }
124
+ return [...differing];
125
+ }
126
+
127
+ /**
128
+ * Ruling R1 extension (PR 292 follow-up, amended on the PR 293 landing
129
+ * review): resolve a same-id create replay against the entry already on
130
+ * the tree. Returns null when the id is free; otherwise the
131
+ * `{ status: 'unchanged' }` result (identical content, nothing is
132
+ * written) or a `kind: 'conflict'` refusal naming the differing field
133
+ * keys (exit 3; never a silent overwrite). Shared by the document path
134
+ * (createDocument), the inline AC / TC paths and the CN path so every
135
+ * `rcf define create <kind> --id` follows one rule.
136
+ *
137
+ * @param {object} args
138
+ * @param {string} args.kind - the CLI kind, for the message
139
+ * @param {string} args.id
140
+ * @param {object | null | undefined} args.existing - the entry on the tree
141
+ * @param {object} args.candidate - the would-be entry
142
+ * @param {string} args.filePath - repo-relative file the entry lives in
143
+ * @param {object} [args.extra] - extra fields carried on the unchanged result
144
+ * @returns {object | null}
145
+ */
146
+ function resolveReplay({ kind, id, existing, candidate, filePath, extra = {} }) {
147
+ if (!existing) return null;
148
+ const differing = diffDocFields(existing, candidate);
149
+ if (differing.length === 0) {
150
+ return { id, filePath, ...extra, body: existing, status: 'unchanged' };
151
+ }
152
+ return rcfError({
153
+ kind: 'conflict',
154
+ message: `create ${kind}: id ${id} already exists with different content; differing fields: ${differing.sort().join(', ')}. Use the update verb to patch.`,
155
+ documentId: id,
156
+ filePath,
157
+ });
158
+ }
159
+
55
160
  // ---------------------------------------------------------------------------
56
161
  // B5: post-write validation gate
57
162
  // ---------------------------------------------------------------------------
@@ -496,17 +601,28 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
496
601
  });
497
602
  }
498
603
 
499
- // Allocate id (or take the override).
604
+ // Allocate id (or take the override). Ruling R1 2026-10-05 extended
605
+ // under w-2026-10-05-dave-013 (PR 292 follow-up): a replayed turn
606
+ // that carries `--id <existing>` with identical content is a no-op
607
+ // that returns `{ status: 'unchanged', body: existing }`; different
608
+ // content refuses with `kind: 'conflict'` naming the differing
609
+ // field keys. An unloadable (schema-invalid) doc still refuses
610
+ // unconditionally -- we cannot compare content against a doc we
611
+ // failed to load.
500
612
  let id = options.id;
613
+ /** @type {object | null} */
614
+ let existingDocForReplay = null;
501
615
  if (id) {
502
- // B5: an unloadable (schema-invalid) doc still occupies its id.
503
- if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
616
+ if (tree.invalidDocs?.has(id)) {
504
617
  return rcfError({
505
618
  kind: 'usage',
506
- message: `create ${kind}: id ${id} is already taken`,
619
+ message: `create ${kind}: id ${id} is already taken (file on disk failed to load; repair before replaying)`,
507
620
  documentId: id,
508
621
  });
509
622
  }
623
+ if (tree.byId.has(id)) {
624
+ existingDocForReplay = tree.byId.get(id);
625
+ }
510
626
  } else {
511
627
  try {
512
628
  id = nextIdForKind(tree, canonical, { parentId });
@@ -528,7 +644,7 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
528
644
  }
529
645
  providedOrder = maxOrder + 1;
530
646
  } else if (Number.isInteger(providedOrder)) {
531
- const collision = siblings.find((sib) => sib.buildOrder === providedOrder);
647
+ const collision = siblings.find((sib) => sib.buildOrder === providedOrder && sib.fbsId !== id);
532
648
  if (collision) {
533
649
  return rcfError({
534
650
  kind: 'usage',
@@ -553,6 +669,16 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
553
669
  });
554
670
  if (validation) return { ...validation, documentId: id };
555
671
 
672
+ // Ruling R1 extension 2026-10-05 (PR 292 follow-up): if the id
673
+ // already exists on the loaded tree, compare the would-be body to
674
+ // the existing doc (ignoring writer-owned timestamps) and either
675
+ // return `{ status: 'unchanged' }` or refuse with
676
+ // `kind: 'conflict'` naming the differing field keys.
677
+ const replay = resolveReplay({
678
+ kind, id, existing: existingDocForReplay, candidate: finalBody, filePath: relPath,
679
+ });
680
+ if (replay) return replay;
681
+
556
682
  // Cross-link ACs must resolve to existing ACs for fbs / ts.
557
683
  if (canonical === 'fbs' || canonical === 'testSuite') {
558
684
  const allAcIds = collectAllAcIds(tree);
@@ -644,10 +770,22 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
644
770
  return rcfError({ kind: 'usage', message: 'create cn: --path is required' });
645
771
  }
646
772
 
773
+ // Ruling R1 extension (PR 293 landing review): a same-id replay is
774
+ // compared against the existing CN once the body is assembled below.
775
+ // An unloadable (schema-invalid) CN, or an id held by another kind,
776
+ // still refuses: there is no CN body to compare against.
647
777
  let id = options.id;
778
+ /** @type {object | null} */
779
+ let existingCnForReplay = null;
648
780
  if (id) {
649
- if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
650
- return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
781
+ if (tree.invalidDocs?.has(id)) {
782
+ return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken (file on disk failed to load; repair before replaying)`, documentId: id });
783
+ }
784
+ if (tree.byId.has(id)) {
785
+ if (tree.kindById.get(id) !== 'codeNode') {
786
+ return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
787
+ }
788
+ existingCnForReplay = tree.byId.get(id);
651
789
  }
652
790
  } else {
653
791
  id = nextIdForKind(tree, 'codeNode');
@@ -706,6 +844,11 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
706
844
  const validation = validateDocument({ doc: finalBody, kind: 'codeNode', filePath: relPath });
707
845
  if (validation) return { ...validation, documentId: id };
708
846
 
847
+ const replay = resolveReplay({
848
+ kind: 'cn', id, existing: existingCnForReplay, candidate: finalBody, filePath: relPath,
849
+ });
850
+ if (replay) return replay;
851
+
709
852
  const absPath = pathForKindFile(projectRoot, 'codeNode', id);
710
853
  if (await fileExistsOnDisk(absPath)) {
711
854
  return rcfError({
@@ -919,12 +1062,14 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
919
1062
  const seedIndex = detectSeededPhantomAcIndex(currentAcs, parentUsId);
920
1063
  const replacingPhantom = seedIndex !== -1 && !options.id;
921
1064
 
1065
+ // Ruling R1 extension (PR 293 landing review): an explicit --id that
1066
+ // already names an AC on the parent US is a replay, resolved against
1067
+ // that AC once the would-be entry is built below.
922
1068
  let acId = options.id;
1069
+ /** @type {object | null} */
1070
+ let existingAcForReplay = null;
923
1071
  if (acId) {
924
- const existing = currentAcs.some((ac) => ac.id === acId);
925
- if (existing) {
926
- return rcfError({ kind: 'usage', message: `create ac: id ${acId} already exists on ${parentUsId}`, documentId: acId });
927
- }
1072
+ existingAcForReplay = currentAcs.find((ac) => ac.id === acId) ?? null;
928
1073
  } else if (replacingPhantom) {
929
1074
  // Reuse the seed's id (AC-<us>-1) so the operator's first AC is -1.
930
1075
  acId = currentAcs[seedIndex].id;
@@ -947,6 +1092,15 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
947
1092
  // consumer, so we only serialise when the CLI passed a value.
948
1093
  ...(body?.determinism !== undefined ? { determinism: body.determinism } : {}),
949
1094
  };
1095
+ const acReplay = resolveReplay({
1096
+ kind: 'ac',
1097
+ id: acId,
1098
+ existing: existingAcForReplay,
1099
+ candidate: acEntry,
1100
+ filePath: `rcf/user-stories/${parentUsId.toLowerCase()}.json`,
1101
+ extra: { parentId: parentUsId },
1102
+ });
1103
+ if (acReplay) return acReplay;
950
1104
  const nextAcs = replacingPhantom
951
1105
  ? currentAcs.map((ac, i) => (i === seedIndex ? acEntry : ac))
952
1106
  : [...currentAcs, acEntry];
@@ -1073,9 +1227,14 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
1073
1227
  return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
1074
1228
  }
1075
1229
  const tcId = options.id ?? `TC-${tsSuffix}-${slug}`;
1076
- // Slug collision fails explicitly (§D10 OQ-P4-R-1).
1077
1230
  const existing = (ts.testCases ?? []).find((tc) => tc.id === tcId);
1078
- if (existing) {
1231
+ // Ruling R1 extension (PR 293 landing review): idempotency is
1232
+ // id-carried. An explicit --id that already names a TC on the parent
1233
+ // TS is a replay, resolved against that TC once the would-be entry is
1234
+ // built below. A derived-slug collision (no --id) still refuses
1235
+ // explicitly (§D10 OQ-P4-R-1).
1236
+ const replayingTc = Boolean(options.id) && Boolean(existing);
1237
+ if (existing && !replayingTc) {
1079
1238
  return rcfError({
1080
1239
  kind: 'usage',
1081
1240
  message: `create tc: slug collision on ${tcId}, supply --slug explicitly`,
@@ -1089,6 +1248,15 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
1089
1248
  status: body?.status ?? 'pending',
1090
1249
  testPointer,
1091
1250
  };
1251
+ const tcReplay = resolveReplay({
1252
+ kind: 'tc',
1253
+ id: tcId,
1254
+ existing: replayingTc ? existing : null,
1255
+ candidate: tcEntry,
1256
+ filePath: `rcf/test-suites/${parentTsId.toLowerCase()}.json`,
1257
+ extra: { parentId: parentTsId },
1258
+ });
1259
+ if (tcReplay) return tcReplay;
1092
1260
  const nextTs = {
1093
1261
  ...ts,
1094
1262
  testCases: [...(ts.testCases ?? []), tcEntry],
@@ -0,0 +1,47 @@
1
+ // Shared define-time helper: resolve the `path:` tokens an engineer
2
+ // named on TAC interfaces against the project root on disk. The pure
3
+ // D3 `shapes:pathsResolve` check reads the resulting set from the
4
+ // stage context; this is the one place I/O happens for the check.
5
+ //
6
+ // Lifted out of `src/cli/readiness.js` in issue 302 (2026-10-06) so
7
+ // the freeze CLI can pre-resolve the same tokens and D3 agrees with
8
+ // readiness instead of false-failing from an empty fallback set.
9
+ // ADR-4131 (0.30.0 PR 5) still names readiness as the origin.
10
+
11
+ import { stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+
14
+ import { extractInterfacePathTokens } from '../query/gates.js';
15
+
16
+ /**
17
+ * Walk the tree's TAC interfaces, extract the `path:` tokens the
18
+ * engineer named, resolve each one against `projectRoot` on disk, and
19
+ * return the set of tokens that resolve.
20
+ *
21
+ * @param {string} projectRoot
22
+ * @param {import('#core/store/walker.js').TreeModel} tree
23
+ * @returns {Promise<Set<string>>}
24
+ */
25
+ export async function resolveInterfacePaths(projectRoot, tree) {
26
+ const resolved = new Set();
27
+ /** @type {Set<string>} */
28
+ const candidates = new Set();
29
+ for (const tac of tree.tacs ?? []) {
30
+ for (const iface of tac.interfaces ?? []) {
31
+ const desc = typeof iface?.description === 'string' ? iface.description : '';
32
+ for (const token of extractInterfacePathTokens(desc)) {
33
+ candidates.add(token);
34
+ }
35
+ }
36
+ }
37
+ await Promise.all([...candidates].map(async (token) => {
38
+ try {
39
+ await stat(join(projectRoot, token));
40
+ resolved.add(token);
41
+ } catch {
42
+ // Missing path stays out of the set; the gate decides whether
43
+ // `authoredAt: D3` lets it through.
44
+ }
45
+ }));
46
+ return resolved;
47
+ }