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.
- package/CHANGELOG.md +46 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/define-draft-shapes.md +15 -10
- package/package.json +1 -1
- package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
- package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
- package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
- package/rcf/code-nodes/cn-771.json +14 -0
- package/rcf/code-nodes/cn-772.json +14 -0
- package/rcf/code-nodes/cn-773.json +16 -0
- package/rcf/code-nodes/cn-774.json +20 -0
- package/rcf/code-nodes/cn-775.json +15 -0
- package/rcf/code-nodes/cn-776.json +17 -0
- package/rcf/code-nodes/cn-777.json +21 -0
- package/rcf/fbs/fbs-202.json +18 -2
- package/rcf/fbs/fbs-203.json +12 -4
- package/rcf/requirements/req-002.json +2 -2
- package/rcf/requirements/req-173.json +2 -2
- package/rcf/requirements/req-174.json +2 -2
- package/rcf/tacs/tac-4122-define-gates.json +7 -2
- package/rcf/test-suites/ts-007.json +1 -1
- package/rcf/test-suites/ts-215.json +64 -1
- package/rcf/test-suites/ts-216.json +18 -2
- package/rcf/test-suites/ts-219.json +10 -2
- package/rcf/test-suites/ts-220.json +18 -2
- package/rcf/test-suites/ts-225.json +10 -2
- package/rcf/test-suites/ts-229.json +49 -3
- package/rcf/test-suites/ts-230.json +26 -2
- package/rcf/test-suites/ts-231.json +16 -1
- package/rcf/test-suites/ts-236.json +44 -0
- package/rcf/user-stories/us-17302.json +36 -1
- package/rcf/user-stories/us-17401.json +11 -1
- package/rcf/user-stories/us-17402.json +6 -1
- package/rcf/user-stories/us-17403.json +11 -1
- package/rcf/user-stories/us-17404.json +16 -1
- package/rcf/user-stories/us-17405.json +7 -2
- package/rcf/user-stories/us-205.json +6 -1
- package/rcf/user-stories/us-206.json +25 -4
- package/rcf/user-stories/us-207.json +36 -0
- package/releases/releases.yaml +11 -1
- package/src/cli/create.js +22 -2
- package/src/cli/ledger.js +96 -4
- package/src/cli/readiness.js +15 -1
- package/src/core/errors/index.js +7 -1
- package/src/core/store/writer.js +181 -13
- package/src/define/ledgers.js +86 -7
- package/src/mcp/tools.js +1 -1
- package/src/query/gates.js +383 -150
- package/src/view/html-page.js +14 -2
- package/src/view/page-init.js +149 -8
- package/src/view/style.css +97 -18
- package/src/view/view-index.js +22 -0
package/src/core/store/writer.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
package/src/define/ledgers.js
CHANGED
|
@@ -102,7 +102,7 @@ export class LedgerError extends Error {
|
|
|
102
102
|
* @param {string} message
|
|
103
103
|
* @param {object} [opts]
|
|
104
104
|
* @param {string} [opts.filePath]
|
|
105
|
-
* @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
|
|
105
|
+
* @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage' | 'conflict'} [opts.code]
|
|
106
106
|
* @param {string} [opts.field]
|
|
107
107
|
*/
|
|
108
108
|
constructor(message, opts = {}) {
|
|
@@ -390,26 +390,105 @@ export function nextIdFor(name, body) {
|
|
|
390
390
|
* updated body. Pure over its inputs (does not write to disk); the
|
|
391
391
|
* CLI calls `saveLedger` with the result.
|
|
392
392
|
*
|
|
393
|
+
* Ruling R1 2026-10-05 (w-2026-10-05-dave-001): `add` is idempotent by
|
|
394
|
+
* id. When `args.id` is set (the harness carries it on a replayed
|
|
395
|
+
* turn):
|
|
396
|
+
* - no entry with that id exists: honour the override and add the
|
|
397
|
+
* entry at that id (any gaps in the id sequence are legal; the
|
|
398
|
+
* per-ledger validator enforces uniqueness, not contiguity);
|
|
399
|
+
* - entry with that id exists and the content matches: return
|
|
400
|
+
* `{ status: 'unchanged', body, entry }`, no new write;
|
|
401
|
+
* - entry with that id exists and the content differs: throw a
|
|
402
|
+
* `LedgerError` (code 'conflict') naming the id and the differing
|
|
403
|
+
* field keys so the CLI exits 3. Update is a separate verb.
|
|
404
|
+
* When `args.id` is omitted the next id is minted as before (the
|
|
405
|
+
* pre-R1 behaviour; a replayed turn without a carried id still works
|
|
406
|
+
* but duplicates the entry at a new id, which is why the harness is
|
|
407
|
+
* responsible for threading --id back).
|
|
408
|
+
*
|
|
393
409
|
* @param {object} args
|
|
394
410
|
* @param {LedgerName} args.name
|
|
395
411
|
* @param {Record<string, unknown[]>} args.body
|
|
396
412
|
* @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
|
|
413
|
+
* @param {number} [args.id] - optional id override (R1)
|
|
397
414
|
* @param {string} [args.now] - ISO timestamp; defaults to Date.now()
|
|
398
|
-
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown
|
|
415
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown>, status?: 'added' | 'unchanged' }}
|
|
399
416
|
*/
|
|
400
|
-
export function addEntry({ name, body, entry, now }) {
|
|
417
|
+
export function addEntry({ name, body, entry, id, now }) {
|
|
401
418
|
const cfg = LEDGER_CONFIG[name];
|
|
402
419
|
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
403
|
-
const
|
|
420
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
421
|
+
let resolvedId;
|
|
422
|
+
if (id !== undefined) {
|
|
423
|
+
if (!Number.isInteger(id) || id < 1) {
|
|
424
|
+
throw new LedgerError(`${name} add --id must be a positive integer, got ${id}.`, {
|
|
425
|
+
filePath: ledgerRelPath(name), field: 'id', code: 'usage',
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const existing = list.find((e) => Number(e?.id) === Number(id));
|
|
429
|
+
if (existing) {
|
|
430
|
+
// R1 idempotency check: compare caller-supplied fields against the
|
|
431
|
+
// existing entry. `addedAt` and `status` default on first add, so
|
|
432
|
+
// they are compared only when the caller explicitly supplied them.
|
|
433
|
+
const differing = [];
|
|
434
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
435
|
+
if (value === undefined) continue;
|
|
436
|
+
if (key === 'id') continue;
|
|
437
|
+
if (key === 'addedAt' || key === 'status') {
|
|
438
|
+
// Default values auto-populated on first add; skip unless the
|
|
439
|
+
// caller passed them explicitly AND they differ.
|
|
440
|
+
if (existing[key] !== value) differing.push(key);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (!deepEqual(existing[key], value)) differing.push(key);
|
|
444
|
+
}
|
|
445
|
+
if (differing.length === 0) {
|
|
446
|
+
return { body, entry: existing, status: 'unchanged' };
|
|
447
|
+
}
|
|
448
|
+
throw new LedgerError(
|
|
449
|
+
`${name} ledger entry ${id} already exists with different content; differing fields: ${differing.sort().join(', ')}. Use the update verb to patch.`,
|
|
450
|
+
{ filePath: ledgerRelPath(name), field: `id ${id}`, code: 'conflict' },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
resolvedId = id;
|
|
454
|
+
} else {
|
|
455
|
+
resolvedId = nextIdFor(name, body);
|
|
456
|
+
}
|
|
404
457
|
const full = {
|
|
405
|
-
id,
|
|
458
|
+
id: resolvedId,
|
|
406
459
|
...entry,
|
|
407
460
|
addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
|
|
408
461
|
status: entry.status ?? 'open',
|
|
409
462
|
};
|
|
410
|
-
const nextBody = { ...body, [cfg.arrayKey]: [...
|
|
463
|
+
const nextBody = { ...body, [cfg.arrayKey]: [...list, full] };
|
|
411
464
|
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
412
|
-
return { body: nextBody, entry: full };
|
|
465
|
+
return { body: nextBody, entry: full, status: 'added' };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Deep-equality over JSON-shaped values. Used by R1's idempotency
|
|
470
|
+
* check to compare a caller-supplied field against the existing entry.
|
|
471
|
+
* Arrays and plain objects are compared structurally; primitives via
|
|
472
|
+
* Object.is. Pure.
|
|
473
|
+
*/
|
|
474
|
+
function deepEqual(a, b) {
|
|
475
|
+
if (Object.is(a, b)) return true;
|
|
476
|
+
if (a === null || b === null) return false;
|
|
477
|
+
if (typeof a !== typeof b) return false;
|
|
478
|
+
if (Array.isArray(a)) {
|
|
479
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
480
|
+
for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
if (typeof a === 'object') {
|
|
484
|
+
if (typeof b !== 'object' || Array.isArray(b)) return false;
|
|
485
|
+
const ak = Object.keys(a);
|
|
486
|
+
const bk = Object.keys(b);
|
|
487
|
+
if (ak.length !== bk.length) return false;
|
|
488
|
+
for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
413
492
|
}
|
|
414
493
|
|
|
415
494
|
/**
|
package/src/mcp/tools.js
CHANGED
|
@@ -639,7 +639,7 @@ const DEFINITIONS = [
|
|
|
639
639
|
properties: {
|
|
640
640
|
kind: { type: 'string', enum: KIND_ENUM, description: 'Document kind' },
|
|
641
641
|
parent: { type: 'string', description: 'Parent document id; required for every kind except cn (which has no parent)' },
|
|
642
|
-
id: { type: 'string', description: 'Override the auto-assigned id
|
|
642
|
+
id: { type: 'string', description: 'Override the auto-assigned id. Idempotent by id: an existing id with identical content is a no-op; different content refuses as a conflict naming the differing fields (use rcf_update to patch)' },
|
|
643
643
|
title: { type: 'string', description: 'Required for req / us / tac / adr / fbs / ts' },
|
|
644
644
|
description: { type: 'string', description: 'Required for ac / tc' },
|
|
645
645
|
purpose: { type: 'string', description: 'Required for ts' },
|