rcf-lite 0.28.1 → 0.28.4

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 (219) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
  6. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  7. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  8. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  9. package/rcf/code-nodes/cn-602.json +3 -2
  10. package/rcf/code-nodes/cn-700.json +23 -0
  11. package/rcf/code-nodes/cn-701.json +14 -0
  12. package/rcf/code-nodes/cn-702.json +14 -0
  13. package/rcf/code-nodes/cn-703.json +16 -0
  14. package/rcf/code-nodes/cn-720.json +15 -0
  15. package/rcf/code-nodes/cn-721.json +15 -0
  16. package/rcf/code-nodes/cn-722.json +15 -0
  17. package/rcf/code-nodes/cn-723.json +18 -0
  18. package/rcf/code-nodes/cn-724.json +14 -0
  19. package/rcf/code-nodes/cn-725.json +21 -0
  20. package/rcf/code-nodes/cn-726.json +17 -0
  21. package/rcf/code-nodes/cn-727.json +14 -0
  22. package/rcf/code-nodes/cn-728.json +21 -0
  23. package/rcf/code-nodes/cn-729.json +18 -0
  24. package/rcf/code-nodes/cn-730.json +14 -0
  25. package/rcf/fbs/fbs-181.json +3 -2
  26. package/rcf/fbs/fbs-190.json +37 -0
  27. package/rcf/fbs/fbs-191.json +34 -0
  28. package/rcf/fbs/fbs-192.json +17 -0
  29. package/rcf/fbs/fbs-193.json +37 -0
  30. package/rcf/fbs/fbs-194.json +35 -0
  31. package/rcf/fbs/fbs-195.json +37 -0
  32. package/rcf/fbs/fbs-196.json +37 -0
  33. package/rcf/fbs/fbs-197.json +41 -0
  34. package/rcf/requirements/req-001.json +2 -1
  35. package/rcf/requirements/req-002.json +2 -1
  36. package/rcf/requirements/req-003.json +2 -1
  37. package/rcf/requirements/req-004.json +2 -1
  38. package/rcf/requirements/req-005.json +2 -1
  39. package/rcf/requirements/req-006.json +2 -1
  40. package/rcf/requirements/req-007.json +2 -1
  41. package/rcf/requirements/req-008.json +2 -1
  42. package/rcf/requirements/req-009.json +2 -1
  43. package/rcf/requirements/req-010.json +5 -2
  44. package/rcf/requirements/req-011.json +2 -1
  45. package/rcf/requirements/req-012.json +2 -1
  46. package/rcf/requirements/req-013.json +2 -1
  47. package/rcf/requirements/req-014.json +2 -1
  48. package/rcf/requirements/req-015.json +2 -1
  49. package/rcf/requirements/req-016.json +5 -2
  50. package/rcf/requirements/req-017.json +6 -2
  51. package/rcf/requirements/req-018.json +5 -2
  52. package/rcf/requirements/req-019.json +5 -2
  53. package/rcf/requirements/req-020.json +5 -2
  54. package/rcf/requirements/req-021.json +5 -2
  55. package/rcf/requirements/req-022.json +5 -2
  56. package/rcf/requirements/req-023.json +6 -4
  57. package/rcf/requirements/req-024.json +5 -2
  58. package/rcf/requirements/req-025.json +5 -2
  59. package/rcf/requirements/req-026.json +5 -2
  60. package/rcf/requirements/req-027.json +5 -2
  61. package/rcf/requirements/req-028.json +5 -2
  62. package/rcf/requirements/req-029.json +5 -2
  63. package/rcf/requirements/req-030.json +5 -2
  64. package/rcf/requirements/req-040.json +5 -2
  65. package/rcf/requirements/req-041.json +5 -2
  66. package/rcf/requirements/req-042.json +5 -2
  67. package/rcf/requirements/req-043.json +5 -2
  68. package/rcf/requirements/req-050.json +5 -2
  69. package/rcf/requirements/req-051.json +5 -2
  70. package/rcf/requirements/req-052.json +5 -2
  71. package/rcf/requirements/req-053.json +5 -2
  72. package/rcf/requirements/req-054.json +5 -2
  73. package/rcf/requirements/req-060.json +5 -2
  74. package/rcf/requirements/req-061.json +5 -2
  75. package/rcf/requirements/req-062.json +5 -2
  76. package/rcf/requirements/req-063.json +5 -2
  77. package/rcf/requirements/req-070.json +5 -2
  78. package/rcf/requirements/req-071.json +5 -2
  79. package/rcf/requirements/req-072.json +5 -2
  80. package/rcf/requirements/req-073.json +5 -2
  81. package/rcf/requirements/req-074.json +5 -2
  82. package/rcf/requirements/req-075.json +5 -2
  83. package/rcf/requirements/req-076.json +5 -2
  84. package/rcf/requirements/req-077.json +5 -2
  85. package/rcf/requirements/req-080.json +5 -2
  86. package/rcf/requirements/req-081.json +5 -2
  87. package/rcf/requirements/req-082.json +5 -2
  88. package/rcf/requirements/req-083.json +5 -2
  89. package/rcf/requirements/req-084.json +5 -2
  90. package/rcf/requirements/req-085.json +5 -2
  91. package/rcf/requirements/req-086.json +5 -2
  92. package/rcf/requirements/req-090.json +5 -2
  93. package/rcf/requirements/req-091.json +5 -2
  94. package/rcf/requirements/req-092.json +5 -2
  95. package/rcf/requirements/req-093.json +5 -2
  96. package/rcf/requirements/req-094.json +5 -2
  97. package/rcf/requirements/req-100.json +5 -2
  98. package/rcf/requirements/req-101.json +5 -2
  99. package/rcf/requirements/req-102.json +5 -2
  100. package/rcf/requirements/req-103.json +5 -2
  101. package/rcf/requirements/req-104.json +5 -2
  102. package/rcf/requirements/req-110.json +5 -2
  103. package/rcf/requirements/req-111.json +5 -2
  104. package/rcf/requirements/req-112.json +5 -2
  105. package/rcf/requirements/req-113.json +5 -2
  106. package/rcf/requirements/req-114.json +5 -2
  107. package/rcf/requirements/req-115.json +5 -2
  108. package/rcf/requirements/req-120.json +5 -2
  109. package/rcf/requirements/req-121.json +5 -2
  110. package/rcf/requirements/req-122.json +5 -2
  111. package/rcf/requirements/req-123.json +5 -2
  112. package/rcf/requirements/req-124.json +5 -2
  113. package/rcf/requirements/req-125.json +5 -2
  114. package/rcf/requirements/req-130.json +6 -2
  115. package/rcf/requirements/req-131.json +6 -2
  116. package/rcf/requirements/req-132.json +6 -2
  117. package/rcf/requirements/req-133.json +6 -2
  118. package/rcf/requirements/req-134.json +6 -2
  119. package/rcf/requirements/req-140.json +5 -2
  120. package/rcf/requirements/req-145.json +5 -2
  121. package/rcf/requirements/req-150.json +6 -2
  122. package/rcf/requirements/req-151.json +6 -2
  123. package/rcf/requirements/req-152.json +6 -2
  124. package/rcf/requirements/req-153.json +6 -2
  125. package/rcf/requirements/req-154.json +5 -2
  126. package/rcf/requirements/req-155.json +4 -1
  127. package/rcf/requirements/req-156.json +4 -1
  128. package/rcf/requirements/req-157.json +4 -1
  129. package/rcf/requirements/req-158.json +4 -1
  130. package/rcf/requirements/req-159.json +4 -1
  131. package/rcf/requirements/req-160.json +4 -1
  132. package/rcf/requirements/req-161.json +4 -1
  133. package/rcf/requirements/req-162.json +4 -1
  134. package/rcf/requirements/req-163.json +5 -2
  135. package/rcf/requirements/req-164.json +5 -2
  136. package/rcf/requirements/req-165.json +5 -2
  137. package/rcf/requirements/req-170.json +33 -0
  138. package/rcf/requirements/req-171.json +44 -0
  139. package/rcf/requirements/req-172.json +25 -0
  140. package/rcf/requirements/req-173.json +25 -0
  141. package/rcf/requirements/req-174.json +25 -0
  142. package/rcf/requirements/req-175.json +25 -0
  143. package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
  144. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  145. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  146. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  147. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  148. package/rcf/test-suites/ts-188.json +10 -2
  149. package/rcf/test-suites/ts-191.json +19 -5
  150. package/rcf/test-suites/ts-204.json +30 -0
  151. package/rcf/test-suites/ts-205.json +22 -0
  152. package/rcf/test-suites/ts-206.json +22 -0
  153. package/rcf/test-suites/ts-207.json +22 -0
  154. package/rcf/test-suites/ts-208.json +22 -0
  155. package/rcf/test-suites/ts-209.json +22 -0
  156. package/rcf/test-suites/ts-210.json +22 -0
  157. package/rcf/test-suites/ts-211.json +22 -0
  158. package/rcf/test-suites/ts-212.json +22 -0
  159. package/rcf/test-suites/ts-213.json +50 -0
  160. package/rcf/test-suites/ts-214.json +74 -0
  161. package/rcf/test-suites/ts-215.json +67 -0
  162. package/rcf/test-suites/ts-216.json +78 -0
  163. package/rcf/test-suites/ts-217.json +62 -0
  164. package/rcf/user-stories/us-15601.json +10 -1
  165. package/rcf/user-stories/us-15701.json +4 -4
  166. package/rcf/user-stories/us-17001.json +27 -0
  167. package/rcf/user-stories/us-17002.json +22 -0
  168. package/rcf/user-stories/us-17003.json +22 -0
  169. package/rcf/user-stories/us-17004.json +22 -0
  170. package/rcf/user-stories/us-17005.json +22 -0
  171. package/rcf/user-stories/us-17006.json +21 -0
  172. package/rcf/user-stories/us-17007.json +21 -0
  173. package/rcf/user-stories/us-17008.json +22 -0
  174. package/rcf/user-stories/us-17101.json +22 -0
  175. package/rcf/user-stories/us-17102.json +22 -0
  176. package/rcf/user-stories/us-17201.json +38 -0
  177. package/rcf/user-stories/us-17301.json +38 -0
  178. package/rcf/user-stories/us-17401.json +58 -0
  179. package/rcf/user-stories/us-17501.json +48 -0
  180. package/releases/releases.yaml +31 -1
  181. package/src/browser-verify/manifest-writer.js +9 -5
  182. package/src/cli/browser-verify.js +9 -1
  183. package/src/cli/feedback.js +316 -32
  184. package/src/cli/help.js +11 -0
  185. package/src/cli/intake.js +25 -4
  186. package/src/cli/ledger.js +399 -0
  187. package/src/cli/preflight.js +24 -3
  188. package/src/cli/readiness.js +280 -0
  189. package/src/cli/review.js +9 -1
  190. package/src/cli/ui-baseline.js +14 -1
  191. package/src/core/store/index.js +5 -0
  192. package/src/core/store/validate-composed-record.js +95 -0
  193. package/src/core/store/writer.js +86 -6
  194. package/src/define/freeze-record.js +232 -0
  195. package/src/define/ledgers.js +513 -0
  196. package/src/feedback/gh.js +27 -1
  197. package/src/feedback/redact-allowlist.json +7 -0
  198. package/src/feedback/redact.js +126 -11
  199. package/src/feedback/render.js +34 -12
  200. package/src/intake/manifest-writer.js +6 -3
  201. package/src/intake/orchestrator.js +44 -7
  202. package/src/preflight/manifest-writer.js +11 -11
  203. package/src/query/delta.js +276 -0
  204. package/src/query/gates.js +832 -0
  205. package/src/query/index.js +36 -0
  206. package/src/query/readiness.js +333 -0
  207. package/src/req-baseline/sweep.js +34 -5
  208. package/src/server/index.js +37 -0
  209. package/src/server/routes.js +65 -3
  210. package/src/ui-baseline/manifest-writer.js +11 -10
  211. package/src/view/doc-renderers/helpers.js +39 -2
  212. package/src/view/doc-renderers/req.js +10 -2
  213. package/src/view/doc-renderers/tad.js +67 -6
  214. package/src/view/doc-renderers/user-story.js +10 -5
  215. package/src/view/html-page.js +347 -28
  216. package/src/view/index.js +14 -1
  217. package/src/view/live-client.js +22 -2
  218. package/src/view/product-map.js +751 -0
  219. package/src/view/style.css +383 -0
@@ -0,0 +1,513 @@
1
+ // Sidecar ledgers under `rcf/define/` (REQ-173; proposal §2.5, §3.2,
2
+ // §8.2 v3).
3
+ //
4
+ // Four ledgers, each one JSON file, each owned by rcf-lite (local
5
+ // schema in this module until schemas 0.7, ADR-4121). Every entry is
6
+ // numbered in one sequence per ledger for the life of the project;
7
+ // numbers never reset, and are what the freeze record's
8
+ // `briefStatements` high-water mark counts against (proposal §2.5).
9
+ //
10
+ // rcf/define/brief-ledger.json D1 brief statements
11
+ // rcf/define/decisions-ledger.json D7 open / answered decisions
12
+ // rcf/define/concern-ledger.json D5 (concern, REQ) pair records
13
+ // rcf/define/probe-ledger.json D6 probe findings
14
+ //
15
+ // Kinds and fields:
16
+ //
17
+ // brief.statements[] { id, kind, text, source, addedAt, status,
18
+ // resolvedAt?, resolvedBy? }
19
+ // kind = capability | constraint | actor | entity | externalSystem
20
+ // | surface | outOfScope | openQuestion | amendment
21
+ //
22
+ // decisions.decisions[] { id, question, options: [{ letter, text }],
23
+ // default: <letter | null>, blocks, addedAt,
24
+ // status, answer?, answeredAt? }
25
+ // `decision list` prints the numbered format from #241: one
26
+ // decision per item; options with letters; the default; what it
27
+ // blocks. Slice 2 (Readiness) is the second consumer.
28
+ //
29
+ // concerns.concerns[] { id, concern, reqId, disposition, reason?,
30
+ // addedAt, status, resolvedAt? }
31
+ // disposition = applied | waived
32
+ //
33
+ // probes.probes[] { id, reqId, finding, severity, addedAt,
34
+ // status, resolvedAt? }
35
+ // severity = low | medium | high
36
+ //
37
+ // status on every entry is `open | resolved`. `resolve` flips it to
38
+ // resolved and stamps `resolvedAt`; `add` stamps `addedAt` and mints
39
+ // the next id (highest current + 1).
40
+ //
41
+ // Loader returns an empty ledger (`{ statements: [] }` etc.) when the
42
+ // file is missing so callers can compose without a per-ledger null
43
+ // check; a malformed file raises a clear error like the freeze
44
+ // record. Every write goes through the writer, which validates the
45
+ // full body first (a bad in-memory record never lands as JSON).
46
+
47
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
48
+ import { dirname, join } from 'node:path';
49
+
50
+ /**
51
+ * The four ledger identifiers. Ledger name -> filename convention.
52
+ * These are the values `rcf define ledger <name>` accepts.
53
+ */
54
+ export const LEDGER_NAMES = /** @type {const} */ (['brief', 'decisions', 'concerns', 'probes']);
55
+
56
+ /** @typedef {(typeof LEDGER_NAMES)[number]} LedgerName */
57
+
58
+ const RELATIVE_DIR = 'rcf/define';
59
+
60
+ /** Per-ledger config: filename, array key on the body, entry schema. */
61
+ const LEDGER_CONFIG = /** @type {const} */ ({
62
+ brief: { file: 'brief-ledger.json', arrayKey: 'statements' },
63
+ decisions: { file: 'decisions-ledger.json', arrayKey: 'decisions' },
64
+ concerns: { file: 'concern-ledger.json', arrayKey: 'concerns' },
65
+ probes: { file: 'probe-ledger.json', arrayKey: 'probes' },
66
+ });
67
+
68
+ export const BRIEF_KINDS = /** @type {const} */ ([
69
+ 'capability', 'constraint', 'actor', 'entity', 'externalSystem',
70
+ 'surface', 'outOfScope', 'openQuestion', 'amendment',
71
+ ]);
72
+
73
+ const CONCERN_DISPOSITIONS = /** @type {const} */ (['applied', 'waived']);
74
+ const PROBE_SEVERITIES = /** @type {const} */ (['low', 'medium', 'high']);
75
+ const STATUS_VALUES = /** @type {const} */ (['open', 'resolved']);
76
+
77
+ /**
78
+ * Local relative path of a ledger file (project-root-relative).
79
+ *
80
+ * @param {LedgerName} name
81
+ * @returns {string}
82
+ */
83
+ export function ledgerRelPath(name) {
84
+ const cfg = LEDGER_CONFIG[name];
85
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
86
+ return `${RELATIVE_DIR}/${cfg.file}`;
87
+ }
88
+
89
+ /**
90
+ * Absolute path of a ledger file for a project root.
91
+ *
92
+ * @param {string} projectRoot
93
+ * @param {LedgerName} name
94
+ * @returns {string}
95
+ */
96
+ export function ledgerPath(projectRoot, name) {
97
+ return join(projectRoot, ledgerRelPath(name));
98
+ }
99
+
100
+ export class LedgerError extends Error {
101
+ /**
102
+ * @param {string} message
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.filePath]
105
+ * @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
106
+ * @param {string} [opts.field]
107
+ */
108
+ constructor(message, opts = {}) {
109
+ super(message);
110
+ this.name = 'LedgerError';
111
+ this.filePath = opts.filePath;
112
+ this.code = opts.code ?? 'schemaFailure';
113
+ this.field = opts.field;
114
+ }
115
+ }
116
+
117
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
118
+
119
+ /**
120
+ * Empty body for a named ledger (matches the shape a loader returns
121
+ * when the file is missing).
122
+ *
123
+ * @param {LedgerName} name
124
+ * @returns {Record<string, unknown>}
125
+ */
126
+ export function emptyLedger(name) {
127
+ const cfg = LEDGER_CONFIG[name];
128
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
129
+ return { [cfg.arrayKey]: [] };
130
+ }
131
+
132
+ /**
133
+ * Validate one entry against its ledger schema. Throws `LedgerError`
134
+ * on the first violation.
135
+ *
136
+ * @param {LedgerName} name
137
+ * @param {unknown} entry
138
+ * @param {string} [filePath]
139
+ * @param {string} [where] path segment for error messages, e.g.
140
+ * 'statements[0]'
141
+ */
142
+ function validateEntry(name, entry, filePath, where = 'entry') {
143
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
144
+ throw new LedgerError(`${name} ${where} must be an object.`, {
145
+ filePath, field: where,
146
+ });
147
+ }
148
+ const e = /** @type {Record<string, unknown>} */ (entry);
149
+
150
+ if (typeof e.id !== 'number' || !Number.isInteger(e.id) || e.id < 1) {
151
+ throw new LedgerError(`${name} ${where}.id must be a positive integer.`, {
152
+ filePath, field: `${where}.id`,
153
+ });
154
+ }
155
+ if (typeof e.addedAt !== 'string' || !ISO_RE.test(e.addedAt)) {
156
+ throw new LedgerError(`${name} ${where}.addedAt must be an ISO-8601 string.`, {
157
+ filePath, field: `${where}.addedAt`,
158
+ });
159
+ }
160
+ if (typeof e.status !== 'string' || !STATUS_VALUES.includes(/** @type {any} */ (e.status))) {
161
+ throw new LedgerError(
162
+ `${name} ${where}.status must be one of: ${STATUS_VALUES.join(', ')}.`,
163
+ { filePath, field: `${where}.status` },
164
+ );
165
+ }
166
+ if (e.status === 'resolved') {
167
+ if (typeof e.resolvedAt !== 'string' || !ISO_RE.test(e.resolvedAt)) {
168
+ throw new LedgerError(
169
+ `${name} ${where}.resolvedAt must be an ISO-8601 string when status is resolved.`,
170
+ { filePath, field: `${where}.resolvedAt` },
171
+ );
172
+ }
173
+ }
174
+
175
+ if (name === 'brief') {
176
+ if (typeof e.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (e.kind))) {
177
+ throw new LedgerError(
178
+ `brief ${where}.kind must be one of: ${BRIEF_KINDS.join(', ')}.`,
179
+ { filePath, field: `${where}.kind` },
180
+ );
181
+ }
182
+ if (typeof e.text !== 'string' || e.text.length === 0) {
183
+ throw new LedgerError(`brief ${where}.text must be a non-empty string.`, {
184
+ filePath, field: `${where}.text`,
185
+ });
186
+ }
187
+ if (e.source !== undefined && typeof e.source !== 'string') {
188
+ throw new LedgerError(`brief ${where}.source must be a string when present.`, {
189
+ filePath, field: `${where}.source`,
190
+ });
191
+ }
192
+ } else if (name === 'decisions') {
193
+ if (typeof e.question !== 'string' || e.question.length === 0) {
194
+ throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
195
+ filePath, field: `${where}.question`,
196
+ });
197
+ }
198
+ if (!Array.isArray(e.options) || e.options.length === 0) {
199
+ throw new LedgerError(`decisions ${where}.options must be a non-empty array.`, {
200
+ filePath, field: `${where}.options`,
201
+ });
202
+ }
203
+ for (let i = 0; i < e.options.length; i += 1) {
204
+ const opt = e.options[i];
205
+ if (
206
+ opt === null || typeof opt !== 'object' || Array.isArray(opt)
207
+ || typeof (/** @type {any} */ (opt)).letter !== 'string'
208
+ || typeof (/** @type {any} */ (opt)).text !== 'string'
209
+ ) {
210
+ throw new LedgerError(
211
+ `decisions ${where}.options[${i}] must be { letter, text }.`,
212
+ { filePath, field: `${where}.options[${i}]` },
213
+ );
214
+ }
215
+ }
216
+ if (e.default !== null && e.default !== undefined && typeof e.default !== 'string') {
217
+ throw new LedgerError(
218
+ `decisions ${where}.default must be a letter string or null.`,
219
+ { filePath, field: `${where}.default` },
220
+ );
221
+ }
222
+ if (e.blocks !== undefined && typeof e.blocks !== 'string') {
223
+ throw new LedgerError(`decisions ${where}.blocks must be a string when present.`, {
224
+ filePath, field: `${where}.blocks`,
225
+ });
226
+ }
227
+ } else if (name === 'concerns') {
228
+ if (typeof e.concern !== 'string' || e.concern.length === 0) {
229
+ throw new LedgerError(`concerns ${where}.concern must be non-empty.`, {
230
+ filePath, field: `${where}.concern`,
231
+ });
232
+ }
233
+ if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
234
+ throw new LedgerError(`concerns ${where}.reqId must be non-empty.`, {
235
+ filePath, field: `${where}.reqId`,
236
+ });
237
+ }
238
+ if (
239
+ typeof e.disposition !== 'string'
240
+ || !CONCERN_DISPOSITIONS.includes(/** @type {any} */ (e.disposition))
241
+ ) {
242
+ throw new LedgerError(
243
+ `concerns ${where}.disposition must be one of: ${CONCERN_DISPOSITIONS.join(', ')}.`,
244
+ { filePath, field: `${where}.disposition` },
245
+ );
246
+ }
247
+ } else if (name === 'probes') {
248
+ if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
249
+ throw new LedgerError(`probes ${where}.reqId must be non-empty.`, {
250
+ filePath, field: `${where}.reqId`,
251
+ });
252
+ }
253
+ if (typeof e.finding !== 'string' || e.finding.length === 0) {
254
+ throw new LedgerError(`probes ${where}.finding must be non-empty.`, {
255
+ filePath, field: `${where}.finding`,
256
+ });
257
+ }
258
+ if (
259
+ typeof e.severity !== 'string'
260
+ || !PROBE_SEVERITIES.includes(/** @type {any} */ (e.severity))
261
+ ) {
262
+ throw new LedgerError(
263
+ `probes ${where}.severity must be one of: ${PROBE_SEVERITIES.join(', ')}.`,
264
+ { filePath, field: `${where}.severity` },
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Validate a full ledger body (the shape a file holds).
272
+ *
273
+ * @param {LedgerName} name
274
+ * @param {unknown} raw
275
+ * @param {string} [filePath]
276
+ * @returns {Record<string, unknown[]>}
277
+ */
278
+ export function validateLedger(name, raw, filePath) {
279
+ const cfg = LEDGER_CONFIG[name];
280
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { filePath, code: 'usage' });
281
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
282
+ throw new LedgerError(`${name} ledger must be a JSON object.`, {
283
+ filePath, field: '(root)',
284
+ });
285
+ }
286
+ const body = /** @type {Record<string, unknown>} */ (raw);
287
+ const list = body[cfg.arrayKey];
288
+ if (!Array.isArray(list)) {
289
+ throw new LedgerError(
290
+ `${name} ledger '${cfg.arrayKey}' must be an array.`,
291
+ { filePath, field: cfg.arrayKey },
292
+ );
293
+ }
294
+ const seen = new Set();
295
+ for (let i = 0; i < list.length; i += 1) {
296
+ const entry = list[i];
297
+ validateEntry(name, entry, filePath, `${cfg.arrayKey}[${i}]`);
298
+ const id = /** @type {any} */ (entry).id;
299
+ if (seen.has(id)) {
300
+ throw new LedgerError(
301
+ `${name} ledger has duplicate id ${id}.`,
302
+ { filePath, field: `${cfg.arrayKey}[${i}].id` },
303
+ );
304
+ }
305
+ seen.add(id);
306
+ }
307
+ return /** @type {Record<string, unknown[]>} */ (body);
308
+ }
309
+
310
+ /**
311
+ * Load a ledger. Returns the empty ledger when the file is absent;
312
+ * throws `LedgerError` on parse / schema failure.
313
+ *
314
+ * @param {object} args
315
+ * @param {string} args.projectRoot
316
+ * @param {LedgerName} args.name
317
+ * @returns {Promise<Record<string, unknown[]>>}
318
+ */
319
+ export async function loadLedger({ projectRoot, name }) {
320
+ const relPath = ledgerRelPath(name);
321
+ const filePath = ledgerPath(projectRoot, name);
322
+ let raw;
323
+ try {
324
+ raw = await readFile(filePath, 'utf8');
325
+ } catch (err) {
326
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
327
+ return emptyLedger(name);
328
+ }
329
+ throw new LedgerError(
330
+ `Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
331
+ { filePath: relPath, code: 'ioFailure' },
332
+ );
333
+ }
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(raw);
337
+ } catch (err) {
338
+ throw new LedgerError(
339
+ `${relPath} parse failed: ${/** @type {Error} */ (err).message}`,
340
+ { filePath: relPath, code: 'parseFailure' },
341
+ );
342
+ }
343
+ return validateLedger(name, parsed, relPath);
344
+ }
345
+
346
+ /**
347
+ * Save a ledger, validating first.
348
+ *
349
+ * @param {object} args
350
+ * @param {string} args.projectRoot
351
+ * @param {LedgerName} args.name
352
+ * @param {Record<string, unknown>} args.body
353
+ * @returns {Promise<{ filePath: string }>}
354
+ */
355
+ export async function saveLedger({ projectRoot, name, body }) {
356
+ const validated = validateLedger(name, body, ledgerRelPath(name));
357
+ const filePath = ledgerPath(projectRoot, name);
358
+ await mkdir(dirname(filePath), { recursive: true });
359
+ await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
360
+ return { filePath };
361
+ }
362
+
363
+ /**
364
+ * Highest currently-used id on a ledger; 0 when empty.
365
+ *
366
+ * @param {LedgerName} name
367
+ * @param {Record<string, unknown[]>} body
368
+ * @returns {number}
369
+ */
370
+ export function nextIdFor(name, body) {
371
+ const cfg = LEDGER_CONFIG[name];
372
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
373
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
374
+ let max = 0;
375
+ for (const e of list) {
376
+ const id = Number(e?.id);
377
+ if (Number.isFinite(id) && id > max) max = id;
378
+ }
379
+ return max + 1;
380
+ }
381
+
382
+ /**
383
+ * Append an entry to a ledger. Mints the next id, stamps `addedAt`,
384
+ * defaults `status: 'open'`, and validates before returning the
385
+ * updated body. Pure over its inputs (does not write to disk); the
386
+ * CLI calls `saveLedger` with the result.
387
+ *
388
+ * @param {object} args
389
+ * @param {LedgerName} args.name
390
+ * @param {Record<string, unknown[]>} args.body
391
+ * @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
392
+ * @param {string} [args.now] - ISO timestamp; defaults to Date.now()
393
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
394
+ */
395
+ export function addEntry({ name, body, entry, now }) {
396
+ const cfg = LEDGER_CONFIG[name];
397
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
398
+ const id = nextIdFor(name, body);
399
+ const full = {
400
+ id,
401
+ ...entry,
402
+ addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
403
+ status: entry.status ?? 'open',
404
+ };
405
+ const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
406
+ validateLedger(name, nextBody, ledgerRelPath(name));
407
+ return { body: nextBody, entry: full };
408
+ }
409
+
410
+ /**
411
+ * Mark an entry `resolved` and stamp `resolvedAt`. Throws `LedgerError`
412
+ * (code `usage`) when the id is unknown. Idempotent-ish: resolving an
413
+ * already-resolved entry refreshes `resolvedAt`.
414
+ *
415
+ * @param {object} args
416
+ * @param {LedgerName} args.name
417
+ * @param {Record<string, unknown[]>} args.body
418
+ * @param {number} args.id
419
+ * @param {Record<string, unknown>} [args.patch] - extra fields (e.g. answer, resolvedBy)
420
+ * @param {string} [args.now]
421
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
422
+ */
423
+ export function resolveEntry({ name, body, id, patch = {}, now }) {
424
+ const cfg = LEDGER_CONFIG[name];
425
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
426
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
427
+ const idx = list.findIndex((e) => Number(e?.id) === Number(id));
428
+ if (idx < 0) {
429
+ throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
430
+ filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
431
+ });
432
+ }
433
+ const updated = {
434
+ ...list[idx],
435
+ ...patch,
436
+ status: 'resolved',
437
+ resolvedAt: patch.resolvedAt ?? now ?? new Date().toISOString(),
438
+ };
439
+ const nextList = [...list];
440
+ nextList[idx] = updated;
441
+ const nextBody = { ...body, [cfg.arrayKey]: nextList };
442
+ validateLedger(name, nextBody, ledgerRelPath(name));
443
+ return { body: nextBody, entry: updated };
444
+ }
445
+
446
+ /**
447
+ * Parse `--from <file>` content into brief-statement drafts: one entry
448
+ * per non-empty line, stripping a leading `- `, `* ` or `<n>. ` bullet.
449
+ * The caller supplies the `kind` (or `capability` as the default). All
450
+ * statements land as `status: 'open'` at the same `addedAt`; the CLI
451
+ * assigns sequential ids.
452
+ *
453
+ * @param {string} content
454
+ * @param {object} [opts]
455
+ * @param {string} [opts.kind] default kind for every extracted line
456
+ * @param {string} [opts.source] optional source-span label per entry
457
+ * @returns {Array<{ text: string, kind: string, source?: string }>}
458
+ */
459
+ export function parseBriefFromFile(content, opts = {}) {
460
+ const kind = opts.kind ?? 'capability';
461
+ /** @type {Array<{ text: string, kind: string, source?: string }>} */
462
+ const drafts = [];
463
+ for (const raw of content.split(/\r?\n/)) {
464
+ const trimmed = raw.trim();
465
+ if (!trimmed) continue;
466
+ const stripped = trimmed
467
+ .replace(/^[-*]\s+/, '')
468
+ .replace(/^\d+[.)]\s+/, '');
469
+ if (!stripped) continue;
470
+ const entry = { text: stripped, kind };
471
+ if (opts.source) entry.source = opts.source;
472
+ drafts.push(entry);
473
+ }
474
+ return drafts;
475
+ }
476
+
477
+ /**
478
+ * Load every ledger the project has actually authored, keyed by name.
479
+ *
480
+ * A project that has never authored a given ledger contributes no
481
+ * `<name>` entry to the returned bundle, so `computeDelta` does not
482
+ * record a `ledger:<name>` docHash for absent ledgers (slice-2 P3
483
+ * resolution: this loader agrees with the `LedgerBundle` typedef on
484
+ * `computeDelta` that "if a project has not authored any decisions,
485
+ * the caller passes no `decisions` key and the delta records no
486
+ * `ledger:decisions` docHash"). Absent-file detection is a `stat` on
487
+ * the ledger path; an ENOENT skips the ledger without loading. A
488
+ * present-but-empty file is still loaded (an operator can `rcf
489
+ * define ledger <name> list --json > <path>` a stub in place; empty
490
+ * ledgers are legal and DO contribute a hash).
491
+ *
492
+ * @param {object} args
493
+ * @param {string} args.projectRoot
494
+ * @returns {Promise<import('../query/delta.js').LedgerBundle>}
495
+ */
496
+ export async function loadAllLedgers({ projectRoot }) {
497
+ /** @type {import('../query/delta.js').LedgerBundle} */
498
+ const bundle = {};
499
+ for (const name of LEDGER_NAMES) {
500
+ const filePath = ledgerPath(projectRoot, name);
501
+ try {
502
+ // eslint-disable-next-line no-await-in-loop
503
+ await stat(filePath);
504
+ } catch (err) {
505
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
506
+ // Any other stat error (permission etc.) surfaces via loadLedger.
507
+ }
508
+ // eslint-disable-next-line no-await-in-loop
509
+ const body = await loadLedger({ projectRoot, name });
510
+ bundle[name] = body;
511
+ }
512
+ return bundle;
513
+ }
@@ -5,8 +5,9 @@
5
5
  // - ghLabelList({ repo }) list existing label names
6
6
  // - ghRepoView({ repo }) visibility + viewerPermission + hasIssuesEnabled
7
7
  // - ghIssueSearch({ repo, query, state, limit }) dedupe search
8
+ // - ghIssueGetBody({ repo, number }) re-read a candidate body for fingerprint verification (0.28.2 / #234)
8
9
  // - ghIssueCreate({ repo, title, body, labels }) create one issue
9
- // - ghIssueComment({ repo, number, body }) +1 comment
10
+ // - ghIssueComment({ repo, number, body }) fold comment (carries the full report; the "+1 from another reporter" payload retired in 0.28.2 per Barry's 2026-09-21 ruling on #234)
10
11
  // - ghLabelCreate({ repo, name, description, color }) bootstrap only
11
12
  //
12
13
  // Every function returns a typed result. Errors are classified into
@@ -68,6 +69,7 @@ export async function loadGhAdapter(env = process.env) {
68
69
  * @property {(opts: { repo: string }) => Promise<GhResult>} ghLabelList
69
70
  * @property {(opts: { repo: string }) => Promise<GhResult>} ghRepoView
70
71
  * @property {(opts: { repo: string, query: string, state?: 'open' | 'closed', limit?: number }) => Promise<GhResult>} ghIssueSearch
72
+ * @property {(opts: { repo: string, number: number }) => Promise<GhResult>} ghIssueGetBody
71
73
  * @property {(opts: { repo: string, title: string, body: string, labels: string[] }) => Promise<GhResult>} ghIssueCreate
72
74
  * @property {(opts: { repo: string, number: number, body: string }) => Promise<GhResult>} ghIssueComment
73
75
  * @property {(opts: { repo: string, name: string, description?: string, color?: string }) => Promise<GhResult>} ghLabelCreate
@@ -189,6 +191,29 @@ export async function ghIssueSearch(opts) {
189
191
  }
190
192
  }
191
193
 
194
+ /**
195
+ * `gh issue view <n> --repo <repo> --json body`. Re-reads the body of
196
+ * a candidate issue for the fingerprint-verification step introduced
197
+ * in 0.28.2 (issue #234): a search hit whose body does not carry the
198
+ * fingerprint line on its own line is a non-match and is discarded
199
+ * before the fold decision. Returns { body }.
200
+ *
201
+ * @param {{ repo: string, number: number }} opts
202
+ * @returns {Promise<GhResult>}
203
+ */
204
+ export async function ghIssueGetBody(opts) {
205
+ const args = ['issue', 'view', String(opts.number), '--repo', opts.repo, '--json', 'body'];
206
+ const r = await runGh(args);
207
+ if (!r.ok) return r;
208
+ try {
209
+ const parsed = JSON.parse(r.value.stdout);
210
+ const body = typeof parsed?.body === 'string' ? parsed.body : '';
211
+ return { ok: true, value: { body } };
212
+ } catch (err) {
213
+ return { ok: false, kind: 'unknown', message: `issue-view parse failed: ${err.message}` };
214
+ }
215
+ }
216
+
192
217
  /**
193
218
  * `gh issue create --repo <repo> --title <t> --body-file - --label
194
219
  * <l1> --label <l2>...` reads the body from stdin so a long body is
@@ -370,6 +395,7 @@ export const DEFAULT_ADAPTER = Object.freeze({
370
395
  ghLabelList,
371
396
  ghRepoView,
372
397
  ghIssueSearch,
398
+ ghIssueGetBody,
373
399
  ghIssueCreate,
374
400
  ghIssueComment,
375
401
  ghLabelCreate,
@@ -10,5 +10,12 @@
10
10
  "developer.mozilla.org",
11
11
  "localhost",
12
12
  "127.0.0.1"
13
+ ],
14
+ "extensions": [
15
+ "md", "mdx", "json", "yml", "yaml", "txt",
16
+ "js", "mjs", "cjs", "ts", "tsx",
17
+ "html", "css",
18
+ "png", "svg",
19
+ "sh", "csv", "log", "lock"
13
20
  ]
14
21
  }