rcf-lite 0.28.2 → 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 (196) hide show
  1. package/CHANGELOG.md +211 -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-700.json +23 -0
  10. package/rcf/code-nodes/cn-701.json +14 -0
  11. package/rcf/code-nodes/cn-702.json +14 -0
  12. package/rcf/code-nodes/cn-703.json +16 -0
  13. package/rcf/code-nodes/cn-720.json +15 -0
  14. package/rcf/code-nodes/cn-721.json +15 -0
  15. package/rcf/code-nodes/cn-722.json +15 -0
  16. package/rcf/code-nodes/cn-723.json +18 -0
  17. package/rcf/code-nodes/cn-724.json +14 -0
  18. package/rcf/code-nodes/cn-725.json +21 -0
  19. package/rcf/code-nodes/cn-726.json +17 -0
  20. package/rcf/code-nodes/cn-727.json +14 -0
  21. package/rcf/code-nodes/cn-728.json +21 -0
  22. package/rcf/code-nodes/cn-729.json +18 -0
  23. package/rcf/code-nodes/cn-730.json +14 -0
  24. package/rcf/fbs/fbs-190.json +37 -0
  25. package/rcf/fbs/fbs-191.json +34 -0
  26. package/rcf/fbs/fbs-192.json +17 -0
  27. package/rcf/fbs/fbs-193.json +37 -0
  28. package/rcf/fbs/fbs-194.json +35 -0
  29. package/rcf/fbs/fbs-195.json +37 -0
  30. package/rcf/fbs/fbs-196.json +37 -0
  31. package/rcf/fbs/fbs-197.json +41 -0
  32. package/rcf/requirements/req-001.json +2 -1
  33. package/rcf/requirements/req-002.json +2 -1
  34. package/rcf/requirements/req-003.json +2 -1
  35. package/rcf/requirements/req-004.json +2 -1
  36. package/rcf/requirements/req-005.json +2 -1
  37. package/rcf/requirements/req-006.json +2 -1
  38. package/rcf/requirements/req-007.json +2 -1
  39. package/rcf/requirements/req-008.json +2 -1
  40. package/rcf/requirements/req-009.json +2 -1
  41. package/rcf/requirements/req-010.json +5 -2
  42. package/rcf/requirements/req-011.json +2 -1
  43. package/rcf/requirements/req-012.json +2 -1
  44. package/rcf/requirements/req-013.json +2 -1
  45. package/rcf/requirements/req-014.json +2 -1
  46. package/rcf/requirements/req-015.json +2 -1
  47. package/rcf/requirements/req-016.json +5 -2
  48. package/rcf/requirements/req-017.json +6 -2
  49. package/rcf/requirements/req-018.json +5 -2
  50. package/rcf/requirements/req-019.json +5 -2
  51. package/rcf/requirements/req-020.json +5 -2
  52. package/rcf/requirements/req-021.json +5 -2
  53. package/rcf/requirements/req-022.json +5 -2
  54. package/rcf/requirements/req-023.json +6 -4
  55. package/rcf/requirements/req-024.json +5 -2
  56. package/rcf/requirements/req-025.json +5 -2
  57. package/rcf/requirements/req-026.json +5 -2
  58. package/rcf/requirements/req-027.json +5 -2
  59. package/rcf/requirements/req-028.json +5 -2
  60. package/rcf/requirements/req-029.json +5 -2
  61. package/rcf/requirements/req-030.json +5 -2
  62. package/rcf/requirements/req-040.json +5 -2
  63. package/rcf/requirements/req-041.json +5 -2
  64. package/rcf/requirements/req-042.json +5 -2
  65. package/rcf/requirements/req-043.json +5 -2
  66. package/rcf/requirements/req-050.json +5 -2
  67. package/rcf/requirements/req-051.json +5 -2
  68. package/rcf/requirements/req-052.json +5 -2
  69. package/rcf/requirements/req-053.json +5 -2
  70. package/rcf/requirements/req-054.json +5 -2
  71. package/rcf/requirements/req-060.json +5 -2
  72. package/rcf/requirements/req-061.json +5 -2
  73. package/rcf/requirements/req-062.json +5 -2
  74. package/rcf/requirements/req-063.json +5 -2
  75. package/rcf/requirements/req-070.json +5 -2
  76. package/rcf/requirements/req-071.json +5 -2
  77. package/rcf/requirements/req-072.json +5 -2
  78. package/rcf/requirements/req-073.json +5 -2
  79. package/rcf/requirements/req-074.json +5 -2
  80. package/rcf/requirements/req-075.json +5 -2
  81. package/rcf/requirements/req-076.json +5 -2
  82. package/rcf/requirements/req-077.json +5 -2
  83. package/rcf/requirements/req-080.json +5 -2
  84. package/rcf/requirements/req-081.json +5 -2
  85. package/rcf/requirements/req-082.json +5 -2
  86. package/rcf/requirements/req-083.json +5 -2
  87. package/rcf/requirements/req-084.json +5 -2
  88. package/rcf/requirements/req-085.json +5 -2
  89. package/rcf/requirements/req-086.json +5 -2
  90. package/rcf/requirements/req-090.json +5 -2
  91. package/rcf/requirements/req-091.json +5 -2
  92. package/rcf/requirements/req-092.json +5 -2
  93. package/rcf/requirements/req-093.json +5 -2
  94. package/rcf/requirements/req-094.json +5 -2
  95. package/rcf/requirements/req-100.json +5 -2
  96. package/rcf/requirements/req-101.json +5 -2
  97. package/rcf/requirements/req-102.json +5 -2
  98. package/rcf/requirements/req-103.json +5 -2
  99. package/rcf/requirements/req-104.json +5 -2
  100. package/rcf/requirements/req-110.json +5 -2
  101. package/rcf/requirements/req-111.json +5 -2
  102. package/rcf/requirements/req-112.json +5 -2
  103. package/rcf/requirements/req-113.json +5 -2
  104. package/rcf/requirements/req-114.json +5 -2
  105. package/rcf/requirements/req-115.json +5 -2
  106. package/rcf/requirements/req-120.json +5 -2
  107. package/rcf/requirements/req-121.json +5 -2
  108. package/rcf/requirements/req-122.json +5 -2
  109. package/rcf/requirements/req-123.json +5 -2
  110. package/rcf/requirements/req-124.json +5 -2
  111. package/rcf/requirements/req-125.json +5 -2
  112. package/rcf/requirements/req-130.json +6 -2
  113. package/rcf/requirements/req-131.json +6 -2
  114. package/rcf/requirements/req-132.json +6 -2
  115. package/rcf/requirements/req-133.json +6 -2
  116. package/rcf/requirements/req-134.json +6 -2
  117. package/rcf/requirements/req-140.json +5 -2
  118. package/rcf/requirements/req-145.json +5 -2
  119. package/rcf/requirements/req-150.json +6 -2
  120. package/rcf/requirements/req-151.json +6 -2
  121. package/rcf/requirements/req-152.json +6 -2
  122. package/rcf/requirements/req-153.json +6 -2
  123. package/rcf/requirements/req-154.json +5 -2
  124. package/rcf/requirements/req-155.json +4 -1
  125. package/rcf/requirements/req-156.json +4 -1
  126. package/rcf/requirements/req-157.json +4 -1
  127. package/rcf/requirements/req-158.json +4 -1
  128. package/rcf/requirements/req-159.json +4 -1
  129. package/rcf/requirements/req-160.json +4 -1
  130. package/rcf/requirements/req-161.json +4 -1
  131. package/rcf/requirements/req-162.json +4 -1
  132. package/rcf/requirements/req-163.json +5 -2
  133. package/rcf/requirements/req-164.json +5 -2
  134. package/rcf/requirements/req-165.json +5 -2
  135. package/rcf/requirements/req-170.json +33 -0
  136. package/rcf/requirements/req-171.json +44 -0
  137. package/rcf/requirements/req-172.json +25 -0
  138. package/rcf/requirements/req-173.json +25 -0
  139. package/rcf/requirements/req-174.json +25 -0
  140. package/rcf/requirements/req-175.json +25 -0
  141. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  142. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  143. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  144. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  145. package/rcf/test-suites/ts-204.json +30 -0
  146. package/rcf/test-suites/ts-205.json +22 -0
  147. package/rcf/test-suites/ts-206.json +22 -0
  148. package/rcf/test-suites/ts-207.json +22 -0
  149. package/rcf/test-suites/ts-208.json +22 -0
  150. package/rcf/test-suites/ts-209.json +22 -0
  151. package/rcf/test-suites/ts-210.json +22 -0
  152. package/rcf/test-suites/ts-211.json +22 -0
  153. package/rcf/test-suites/ts-212.json +22 -0
  154. package/rcf/test-suites/ts-213.json +50 -0
  155. package/rcf/test-suites/ts-214.json +74 -0
  156. package/rcf/test-suites/ts-215.json +67 -0
  157. package/rcf/test-suites/ts-216.json +78 -0
  158. package/rcf/test-suites/ts-217.json +62 -0
  159. package/rcf/user-stories/us-15601.json +4 -4
  160. package/rcf/user-stories/us-17001.json +27 -0
  161. package/rcf/user-stories/us-17002.json +22 -0
  162. package/rcf/user-stories/us-17003.json +22 -0
  163. package/rcf/user-stories/us-17004.json +22 -0
  164. package/rcf/user-stories/us-17005.json +22 -0
  165. package/rcf/user-stories/us-17006.json +21 -0
  166. package/rcf/user-stories/us-17007.json +21 -0
  167. package/rcf/user-stories/us-17008.json +22 -0
  168. package/rcf/user-stories/us-17101.json +22 -0
  169. package/rcf/user-stories/us-17102.json +22 -0
  170. package/rcf/user-stories/us-17201.json +38 -0
  171. package/rcf/user-stories/us-17301.json +38 -0
  172. package/rcf/user-stories/us-17401.json +58 -0
  173. package/rcf/user-stories/us-17501.json +48 -0
  174. package/releases/releases.yaml +21 -1
  175. package/src/cli/feedback.js +126 -10
  176. package/src/cli/help.js +11 -0
  177. package/src/cli/ledger.js +399 -0
  178. package/src/cli/readiness.js +280 -0
  179. package/src/core/store/writer.js +86 -6
  180. package/src/define/freeze-record.js +232 -0
  181. package/src/define/ledgers.js +513 -0
  182. package/src/feedback/redact.js +105 -19
  183. package/src/query/delta.js +276 -0
  184. package/src/query/gates.js +832 -0
  185. package/src/query/index.js +36 -0
  186. package/src/query/readiness.js +333 -0
  187. package/src/server/index.js +37 -0
  188. package/src/server/routes.js +65 -3
  189. package/src/view/doc-renderers/helpers.js +2 -2
  190. package/src/view/doc-renderers/req.js +10 -2
  191. package/src/view/doc-renderers/user-story.js +10 -5
  192. package/src/view/html-page.js +347 -28
  193. package/src/view/index.js +14 -1
  194. package/src/view/live-client.js +22 -2
  195. package/src/view/product-map.js +751 -0
  196. package/src/view/style.css +378 -0
@@ -54,7 +54,7 @@ import {
54
54
  emitSessionStart,
55
55
  shouldAsk,
56
56
  } from '../feedback/hook.js';
57
- import { redact, allowedHosts, findResidualSecrets } from '../feedback/redact.js';
57
+ import { redact, allowedHosts, findResidualSecrets, RESERVED_IDENTITY_TOKENS } from '../feedback/redact.js';
58
58
  import { fingerprint } from '../feedback/fingerprint.js';
59
59
  import { renderIssue, renderComment, renderBundle } from '../feedback/render.js';
60
60
  import { resolve as resolveDestination, listUnresolvedLibraries, findBlueprintRecord } from '../feedback/destination.js';
@@ -477,17 +477,58 @@ async function handleAdd(argv, ctx) {
477
477
  }
478
478
 
479
479
  /**
480
- * Classify an evidence pointer into `{ kind, value }`. A colon-separated
481
- * `path:line` pattern is 'file'; a bare identifier that looks like an
482
- * upper-cased id is 'id'; anything else is 'command'. The classification
483
- * is best-effort and does not gate the write.
480
+ * Classify an evidence pointer into `{ kind, value }`. The help text
481
+ * promises three shapes: command / path:line / id. The classifier
482
+ * distinguishes them by cheap shape rules:
483
+ *
484
+ * - `id`: a canonical uppercase-prefix RCF artefact id (`AC-15501-1`,
485
+ * `REQ-155`, `FBS-181`, `US-15701`).
486
+ * - `file`: `path:line` (a non-space token ending in `:<digits>`) OR
487
+ * a bare filesystem path shape - a slash-bearing, non-whitespace
488
+ * token that ends in a known file extension. This is the shape a
489
+ * `--evidence rcf/.blueprint-libraries/wsd/1.0.0/library.json`
490
+ * argument carries (issue #245).
491
+ * - `command`: anything else. This is the fallback shape for a bare
492
+ * verb invocation like `rcf feedback preview` or `pnpm --filter
493
+ * rcf-lite test`.
494
+ *
495
+ * Classification is best-effort and never gates the write; the value
496
+ * itself is always preserved verbatim. The rendered preview label
497
+ * uses this kind so the operator sees which shape the pointer carries
498
+ * without having to re-run it.
484
499
  *
485
500
  * @param {string} value
486
501
  * @returns {{ kind: 'file' | 'id' | 'command', value: string }}
487
502
  */
503
+ // 0.28.3 (issue #245): extension shortlist used by `classifyEvidence`
504
+ // to recognise bare file-path evidence. Kept in lockstep with the
505
+ // redactor's default extension allowlist so operators see the same
506
+ // treatment on both sides. Duplicated deliberately (not imported) to
507
+ // keep the CLI light on cross-module reads for a per-call classifier.
508
+ const EVIDENCE_FILE_EXTENSIONS = new Set([
509
+ 'md', 'mdx', 'json', 'yml', 'yaml', 'txt',
510
+ 'js', 'mjs', 'cjs', 'ts', 'tsx',
511
+ 'html', 'css', 'png', 'svg', 'sh', 'csv', 'log', 'lock',
512
+ ]);
513
+
488
514
  export function classifyEvidence(value) {
515
+ if (typeof value !== 'string' || value.length === 0) return { kind: 'command', value };
516
+ // Canonical RCF id shape: uppercase prefix, then digits (e.g. AC-15501-1).
489
517
  if (/^[A-Z]+-\d[\w-]*$/.test(value)) return { kind: 'id', value };
490
- if (/^[^\s].*:\d+$/.test(value)) return { kind: 'file', value };
518
+ // path:line - a non-whitespace token ending in ":<digits>".
519
+ if (/^[^\s]+:\d+$/.test(value)) return { kind: 'file', value };
520
+ // Bare filesystem path or filename: no whitespace, ends in a known
521
+ // extension (issue #245). Whitespace anywhere disqualifies the
522
+ // candidate; that is almost certainly a shell command. A bare
523
+ // filename without a slash still counts because a --evidence
524
+ // pointer to README.md or CHANGELOG.md is common.
525
+ if (!/\s/.test(value)) {
526
+ const lastDot = value.lastIndexOf('.');
527
+ if (lastDot > 0 && lastDot < value.length - 1) {
528
+ const ext = value.slice(lastDot + 1).toLowerCase();
529
+ if (EVIDENCE_FILE_EXTENSIONS.has(ext)) return { kind: 'file', value };
530
+ }
531
+ }
491
532
  return { kind: 'command', value };
492
533
  }
493
534
 
@@ -624,6 +665,14 @@ async function handleStatus(argv, ctx) {
624
665
  visibility: dest.visibility ?? 'unresolved',
625
666
  derived: !!dest.derived,
626
667
  reason: dest.reason ?? null,
668
+ // Issue #244 (0.28.3): the preview surface prints
669
+ // `source: <library-manifest|package-bugs-url|derived|...>` so
670
+ // the operator can see WHICH resolver path picked the destination.
671
+ // The --json surface must expose the same field for programmatic
672
+ // verifiers per AC-15502-2 (--json emits the same as text). The
673
+ // field is null when the resolver did not attach one (e.g. an
674
+ // unresolved library).
675
+ source: dest.source ?? null,
627
676
  });
628
677
  }
629
678
 
@@ -672,6 +721,12 @@ async function handleStatus(argv, ctx) {
672
721
  ? `${row.repo} (${row.visibility}${row.derived ? ', derived' : ''})`
673
722
  : `(unresolved${row.reason ? `: ${row.reason}` : ''})`;
674
723
  stdout.write(` - ${row.ref} -> ${cell}\n`);
724
+ // Issue #244 (0.28.3): print the resolver source so text and
725
+ // --json carry the same disclosure. Preview already prints this;
726
+ // status now matches per AC-15502-2 parity.
727
+ if (row.source) {
728
+ stdout.write(` source: ${row.source}\n`);
729
+ }
675
730
  }
676
731
  }
677
732
  return 0;
@@ -1003,15 +1058,76 @@ async function handlePreview(argv, ctx) {
1003
1058
  */
1004
1059
  async function buildRedactionContext(projectRoot) {
1005
1060
  let operatorName;
1061
+ // 0.28.3 (R5g, issue #246): track why identity folding might be
1062
+ // disabled so we can emit exactly one visible stderr warning
1063
+ // instead of leaving the operator to guess. `disabledReason` is
1064
+ // one of 'no-profile' (rcf/.identity/profile.md is missing or
1065
+ // unreadable), 'no-name-line' (present but no `## Name` heading),
1066
+ // 'blank-name' (whitespace-only), 'placeholder' (matches the
1067
+ // placeholder/todo regex), 'stopword-only' (Name contains only
1068
+ // reserved-noun tokens), or null when a foldable identity was
1069
+ // extracted.
1070
+ let disabledReason = 'no-profile';
1006
1071
  try {
1007
1072
  const profile = await readFile(resolve(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
1008
- const m = profile.match(/^##\s+Name\s*\n([^\n]+)/m);
1009
- if (m) {
1010
- const n = m[1].trim();
1011
- if (n && !/placeholder|todo|your name/i.test(n)) operatorName = n;
1073
+ // Codex P1-2: extract the Name-section body up to the next `## `
1074
+ // heading or EOF, using ONLY horizontal whitespace on the heading
1075
+ // line so an empty section (`## Name\n\n## Role\n...`) does not
1076
+ // greedy-match into the next section's heading. Bind the search
1077
+ // to the first `## Name` header only.
1078
+ // JS regex has no `\Z`; use a lookahead for the next `## ` header
1079
+ // OR end of string. `[\s\S]*?` is lazy so it stops at the first
1080
+ // matching lookahead.
1081
+ const nameSection = profile.match(/^##[ \t]+Name[ \t]*\n([\s\S]*?)(?=^##[ \t]+|$(?![\s\S]))/m);
1082
+ if (!nameSection) {
1083
+ disabledReason = 'no-name-line';
1084
+ } else {
1085
+ // The first non-empty non-comment line inside the Name section is
1086
+ // the operator's identity. Strip whitespace and leading/trailing
1087
+ // markdown emphasis so a shipped placeholder like
1088
+ // `_(who you are; how you want the agent to address you)_` is
1089
+ // seen as text, not folded as a name token.
1090
+ const nonEmpty = nameSection[1].split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
1091
+ const first = nonEmpty[0];
1092
+ if (!first) {
1093
+ disabledReason = 'blank-name';
1094
+ } else if (/^_\(.*\)_$/i.test(first) || /placeholder|todo|your name/i.test(first) || /^_.*(who you are|how you want).*_$/i.test(first)) {
1095
+ // Codex P1-3: recognise the shipped seed placeholder shape
1096
+ // `_(who you are; how you want the agent to address you)_`
1097
+ // as a placeholder so the untouched generated profile emits
1098
+ // the disabled-folding warning rather than folding the
1099
+ // placeholder text as a name.
1100
+ disabledReason = 'placeholder';
1101
+ } else {
1102
+ operatorName = first;
1103
+ const rawTokens = first.split(/\s+/);
1104
+ const foldable = rawTokens.filter((t) => t.length > 3 && !RESERVED_IDENTITY_TOKENS.has(t.toLowerCase()));
1105
+ if (foldable.length === 0) {
1106
+ disabledReason = 'stopword-only';
1107
+ } else {
1108
+ disabledReason = null;
1109
+ }
1110
+ }
1012
1111
  }
1013
1112
  } catch { /* absent is fine */ }
1014
1113
 
1114
+ // Emit the one-line warning once per context build. All disabled
1115
+ // reasons carry the same fix pointer so the operator knows how to
1116
+ // arm folding without having to read the source.
1117
+ if (disabledReason) {
1118
+ const path = 'rcf/.identity/profile.md';
1119
+ const detail = {
1120
+ 'no-profile': `${path} is missing`,
1121
+ 'no-name-line': `${path} has no \`## Name\` heading`,
1122
+ 'blank-name': `${path} \`## Name\` line is empty`,
1123
+ 'placeholder': `${path} \`## Name\` line is a placeholder`,
1124
+ 'stopword-only': `${path} \`## Name\` line contains only reserved nouns`,
1125
+ }[disabledReason] ?? path;
1126
+ process.stderr.write(
1127
+ `[warn] operator-identity: ${detail}; identity folding is disabled. Add a real \`## Name\` line to arm the redactor.\n`
1128
+ );
1129
+ }
1130
+
1015
1131
  let projectName;
1016
1132
  const remotes = new Set();
1017
1133
  try {
package/src/cli/help.js CHANGED
@@ -45,6 +45,8 @@ import { HELP as REQ_BASELINE_HELP } from './req-baseline.js';
45
45
  import { HELP as INTAKE_HELP } from './intake.js';
46
46
  import { HELP as BLUEPRINT_HELP } from './blueprint.js';
47
47
  import { HELP as STANDARDS_HELP } from './standards.js';
48
+ import { HELP as LEDGER_HELP } from './ledger.js';
49
+ import { HELP as READINESS_HELP } from './readiness.js';
48
50
 
49
51
  // Verify group members (from the verify-suite subtree).
50
52
  import { HELP as VERIFY_RUN_HELP } from '../verify/cli/run.js';
@@ -93,6 +95,8 @@ define Author the RCF tree.
93
95
  design <fbs-id> <verb> Design substage for a UI-bearing FBS.
94
96
  blueprint <verb> Compose blueprints onto the project.
95
97
  standards <verb> Register standards packs against the project.
98
+ ledger <name> <verb> Sidecar-ledger CRUD under rcf/define/.
99
+ readiness DEFINE stage-gate readiness view.
96
100
 
97
101
  build Drive the FBS queue and the five-stage build cycle.
98
102
  queue Show the FBS build queue.
@@ -189,6 +193,11 @@ Verbs:
189
193
  design <fbs-id> <verb> Design substage for a UI-bearing FBS.
190
194
  blueprint <verb> Compose blueprints onto the project.
191
195
  standards <verb> Register standards packs against the project.
196
+ ledger <name> <verb> Sidecar-ledger CRUD under rcf/define/
197
+ (brief | decisions | concerns | probes;
198
+ verbs: add | resolve | list).
199
+ readiness DEFINE stage-gate readiness view; twin of
200
+ the viewer's Readiness tab (slice 5).
192
201
 
193
202
  Run 'rcf help define <verb>' for per-verb help.
194
203
  `;
@@ -284,6 +293,8 @@ export const HELP_MAP = {
284
293
  design: DESIGN_HELP,
285
294
  blueprint: BLUEPRINT_HELP,
286
295
  standards: STANDARDS_HELP,
296
+ ledger: LEDGER_HELP,
297
+ readiness: READINESS_HELP,
287
298
  },
288
299
  build: {
289
300
  queue: BUILD_HELP,
@@ -0,0 +1,399 @@
1
+ // `rcf define ledger <brief|decisions|concerns|probes> <add|resolve|list>`
2
+ // (REQ-173; proposal §8.2 v3).
3
+ //
4
+ // Sidecar-ledger CRUD. The intake-scan-against-frozen-statements part
5
+ // of `brief add --from <file>` is a seam here (slice 1 does not wire
6
+ // it): `rcf discover intake` is the intended caller in a later slice
7
+ // and the CLI exposes `--kind` and `--source` so a caller can feed
8
+ // them in. Exit codes match the rest of the CLI: 0 success, 2 usage
9
+ // error, 1 unexpected runtime failure.
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+ import { parseArgs } from 'node:util';
13
+
14
+ import {
15
+ BRIEF_KINDS,
16
+ LEDGER_NAMES,
17
+ LedgerError,
18
+ addEntry,
19
+ loadLedger,
20
+ parseBriefFromFile,
21
+ resolveEntry,
22
+ saveLedger,
23
+ } from '../define/ledgers.js';
24
+ import { findProjectRoot } from '../view/index.js';
25
+
26
+ const OPTION_SPEC = {
27
+ kind: { type: 'string' },
28
+ text: { type: 'string' },
29
+ from: { type: 'string' },
30
+ source: { type: 'string' },
31
+ reason: { type: 'string' },
32
+ answer: { type: 'string' },
33
+ question: { type: 'string' },
34
+ option: { type: 'string', multiple: true },
35
+ default: { type: 'string' },
36
+ blocks: { type: 'string' },
37
+ req: { type: 'string' },
38
+ concern: { type: 'string' },
39
+ disposition: { type: 'string' },
40
+ finding: { type: 'string' },
41
+ severity: { type: 'string' },
42
+ json: { type: 'boolean' },
43
+ help: { type: 'boolean' },
44
+ };
45
+
46
+ export const HELP = `Usage: rcf define ledger <name> <verb> [options]
47
+
48
+ Ledgers (proposal 2026-09-22 §2.5, §3.2 v3) are sidecars under
49
+ rcf/define/. Four ledgers, each one JSON file, each with one numbered
50
+ sequence for the life of the project.
51
+
52
+ Ledger names:
53
+ brief D1 brief statements (kinded from a closed set)
54
+ decisions D7 open / answered decisions (#241 format)
55
+ concerns D5 (concern, REQ) pairs (applied | waived)
56
+ probes D6 probe findings (severity: low | medium | high)
57
+
58
+ Verbs:
59
+ add Append a new entry, next id
60
+ resolve <id> Flip status to resolved, stamp resolvedAt
61
+ list Print every entry (JSON with --json)
62
+
63
+ Common flags:
64
+ --json Emit machine-readable envelope on list
65
+ --help Print this help
66
+
67
+ brief add flags:
68
+ --kind <${BRIEF_KINDS.join('|')}>
69
+ Statement kind (default: capability)
70
+ --text <text> The statement text (mutually exclusive with --from)
71
+ --from <path> Read one statement per non-empty line from a file
72
+ --source <span> Optional source span label (file:line, URL, note)
73
+
74
+ decisions add flags:
75
+ --question <text> The decision question (required)
76
+ --option <letter:text> Repeatable option, format 'a:First option'
77
+ --default <letter> Default option letter
78
+ --blocks <text> What this decision blocks (a gate id, doc id)
79
+
80
+ concerns add flags:
81
+ --req <req-id> REQ the (concern, REQ) pair belongs to (required)
82
+ --concern <slug> Concern key (auth, retention, ...) (required)
83
+ --disposition applied|waived
84
+ Disposition (required)
85
+ --reason <text> Reason (mandatory when disposition=waived)
86
+
87
+ probes add flags:
88
+ --req <req-id> REQ the probe finding sits on (required)
89
+ --finding <text> The finding text (required)
90
+ --severity low|medium|high
91
+
92
+ resolve flags:
93
+ --answer <text> On decisions: the answer chosen (a letter, or free text)
94
+ --reason <text> On probes / concerns: the resolution note
95
+
96
+ Notes:
97
+ - Every ledger file lives under rcf/define/. The loader treats missing
98
+ files as empty ledgers; a malformed file is a hard error.
99
+ - The 'brief add --from <path>' seam for running rcf discover intake
100
+ against the frozen statements is wired in a later slice; this slice
101
+ only splits by non-empty lines and mints ids.
102
+ - 'decisions list' prints the numbered format from #241: one decision
103
+ per item; options with letters; the default; what it blocks.
104
+ `;
105
+
106
+ /**
107
+ * @param {string[]} argv - argv slice after `ledger`
108
+ * @param {object} [deps]
109
+ * @returns {Promise<number>}
110
+ */
111
+ export async function main(argv, deps = {}) {
112
+ const stdout = deps.stdout ?? process.stdout;
113
+ const stderr = deps.stderr ?? process.stderr;
114
+ const cwd = deps.cwd ?? process.cwd();
115
+
116
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
117
+ stdout.write(HELP);
118
+ return argv.length === 0 ? 2 : 0;
119
+ }
120
+
121
+ const name = argv[0];
122
+ const verb = argv[1];
123
+ if (!LEDGER_NAMES.includes(/** @type {any} */ (name))) {
124
+ stderr.write(`[error] usage ledger: unknown name '${name}' (expected: ${LEDGER_NAMES.join(', ')})\n`);
125
+ return 2;
126
+ }
127
+ if (!verb || !['add', 'resolve', 'list'].includes(verb)) {
128
+ stderr.write("[error] usage ledger: expected verb 'add', 'resolve' or 'list'\n");
129
+ return 2;
130
+ }
131
+
132
+ let parsed;
133
+ try {
134
+ parsed = parseArgs({
135
+ args: argv.slice(2), options: OPTION_SPEC, allowPositionals: true, strict: true,
136
+ });
137
+ } catch (err) {
138
+ stderr.write(`[error] usage ${/** @type {Error} */ (err).message}\n`);
139
+ return 2;
140
+ }
141
+ const flags = parsed.values;
142
+ const positionals = parsed.positionals;
143
+ if (flags.help) { stdout.write(HELP); return 0; }
144
+
145
+ const projectRoot = await findProjectRoot(cwd);
146
+ if (!projectRoot) {
147
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor). Run `npx rcf init` to create and wire a project.\n');
148
+ return 2;
149
+ }
150
+
151
+ try {
152
+ if (verb === 'list') {
153
+ return await runList({ projectRoot, name, flags, stdout });
154
+ }
155
+ if (verb === 'add') {
156
+ return await runAdd({ projectRoot, name, flags, stdout, stderr });
157
+ }
158
+ return await runResolve({ projectRoot, name, positionals, flags, stdout, stderr });
159
+ } catch (err) {
160
+ if (err instanceof LedgerError) {
161
+ stderr.write(`[error] ${err.code === 'usage' ? 'usage ' : ''}ledger: ${err.message}\n`);
162
+ return err.code === 'usage' ? 2 : 1;
163
+ }
164
+ stderr.write(`[error] ledger: ${/** @type {Error} */ (err).message}\n`);
165
+ return 1;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * @param {object} args
171
+ * @param {string} args.projectRoot
172
+ * @param {import('../define/ledgers.js').LedgerName} args.name
173
+ * @param {Record<string, unknown>} args.flags
174
+ * @param {NodeJS.WriteStream} args.stdout
175
+ * @returns {Promise<number>}
176
+ */
177
+ async function runList({ projectRoot, name, flags, stdout }) {
178
+ const body = await loadLedger({ projectRoot, name });
179
+ if (flags.json) {
180
+ stdout.write(`${JSON.stringify({ ledger: name, ...body }, null, 2)}\n`);
181
+ return 0;
182
+ }
183
+ if (name === 'decisions') {
184
+ stdout.write(renderDecisionsList(body));
185
+ return 0;
186
+ }
187
+ stdout.write(renderGenericList(name, body));
188
+ return 0;
189
+ }
190
+
191
+ /**
192
+ * Render decisions in the numbered #241 format:
193
+ * 1. <question> [status]
194
+ * a) <option a>
195
+ * b) <option b>
196
+ * default: <letter>
197
+ * blocks: <text>
198
+ * answer: <text> (when resolved)
199
+ *
200
+ * @param {Record<string, any>} body
201
+ * @returns {string}
202
+ */
203
+ export function renderDecisionsList(body) {
204
+ const list = /** @type {any[]} */ (body.decisions ?? []);
205
+ if (list.length === 0) return '(no decisions)\n';
206
+ const lines = [];
207
+ for (const d of list) {
208
+ const status = d.status === 'resolved' ? 'resolved' : 'open';
209
+ lines.push(`${d.id}. ${d.question} [${status}]`);
210
+ for (const opt of d.options ?? []) {
211
+ lines.push(` ${opt.letter}) ${opt.text}`);
212
+ }
213
+ if (d.default != null) lines.push(` default: ${d.default}`);
214
+ if (d.blocks) lines.push(` blocks: ${d.blocks}`);
215
+ if (status === 'resolved' && d.answer != null) lines.push(` answer: ${d.answer}`);
216
+ lines.push('');
217
+ }
218
+ return `${lines.join('\n').trimEnd()}\n`;
219
+ }
220
+
221
+ /**
222
+ * @param {import('../define/ledgers.js').LedgerName} name
223
+ * @param {Record<string, any>} body
224
+ * @returns {string}
225
+ */
226
+ function renderGenericList(name, body) {
227
+ const key = name === 'brief' ? 'statements' : name;
228
+ const list = /** @type {any[]} */ (body[key] ?? []);
229
+ if (list.length === 0) return `(no ${name} entries)\n`;
230
+ const lines = [];
231
+ for (const e of list) {
232
+ const status = e.status === 'resolved' ? 'resolved' : 'open';
233
+ if (name === 'brief') {
234
+ lines.push(`${e.id}. [${e.kind}] ${e.text} (${status})`);
235
+ if (e.source) lines.push(` source: ${e.source}`);
236
+ } else if (name === 'concerns') {
237
+ lines.push(`${e.id}. ${e.concern} on ${e.reqId}: ${e.disposition} (${status})`);
238
+ if (e.reason) lines.push(` reason: ${e.reason}`);
239
+ } else if (name === 'probes') {
240
+ lines.push(`${e.id}. [${e.severity}] ${e.reqId}: ${e.finding} (${status})`);
241
+ }
242
+ }
243
+ return `${lines.join('\n')}\n`;
244
+ }
245
+
246
+ /**
247
+ * @param {object} args
248
+ */
249
+ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
250
+ const body = await loadLedger({ projectRoot, name });
251
+ const now = new Date().toISOString();
252
+
253
+ if (name === 'brief') {
254
+ const kind = /** @type {string} */ (flags.kind ?? 'capability');
255
+ if (!BRIEF_KINDS.includes(/** @type {any} */ (kind))) {
256
+ stderr.write(`[error] usage ledger: brief --kind must be one of: ${BRIEF_KINDS.join(', ')}\n`);
257
+ return 2;
258
+ }
259
+ if (flags.from && flags.text) {
260
+ stderr.write('[error] usage ledger: brief add takes --text OR --from, not both\n');
261
+ return 2;
262
+ }
263
+ if (!flags.from && !flags.text) {
264
+ stderr.write('[error] usage ledger: brief add requires --text <text> or --from <path>\n');
265
+ return 2;
266
+ }
267
+ let drafts;
268
+ if (flags.from) {
269
+ const raw = await readFile(String(flags.from), 'utf8');
270
+ drafts = parseBriefFromFile(raw, { kind, source: flags.source ? String(flags.source) : String(flags.from) });
271
+ if (drafts.length === 0) {
272
+ stderr.write(`[error] usage ledger: brief --from ${flags.from} produced no non-empty lines\n`);
273
+ return 2;
274
+ }
275
+ } else {
276
+ const entry = { text: String(flags.text), kind };
277
+ if (flags.source) entry.source = String(flags.source);
278
+ drafts = [entry];
279
+ }
280
+ // The intake-scan-against-frozen-statements seam: a later slice
281
+ // calls the intake scans over drafts + the currently-frozen ledger
282
+ // here. This slice mints ids and writes.
283
+ let cur = body;
284
+ const added = [];
285
+ for (const d of drafts) {
286
+ const step = addEntry({ name: 'brief', body: cur, entry: d, now });
287
+ cur = step.body;
288
+ added.push(step.entry);
289
+ }
290
+ await saveLedger({ projectRoot, name: 'brief', body: cur });
291
+ stdout.write(`brief-ledger: added ${added.length} statement(s), ids ${added.map((e) => e.id).join(', ')}\n`);
292
+ return 0;
293
+ }
294
+
295
+ if (name === 'decisions') {
296
+ if (!flags.question) {
297
+ stderr.write('[error] usage ledger: decisions add requires --question <text>\n');
298
+ return 2;
299
+ }
300
+ const options = parseOptionFlags(flags.option);
301
+ if (options.error) {
302
+ stderr.write(`[error] usage ledger: ${options.error}\n`);
303
+ return 2;
304
+ }
305
+ const entry = {
306
+ question: String(flags.question),
307
+ options: options.value,
308
+ default: flags.default ? String(flags.default) : null,
309
+ };
310
+ if (flags.blocks) entry.blocks = String(flags.blocks);
311
+ const step = addEntry({ name: 'decisions', body, entry, now });
312
+ await saveLedger({ projectRoot, name: 'decisions', body: step.body });
313
+ stdout.write(`decisions-ledger: added decision ${step.entry.id}\n`);
314
+ return 0;
315
+ }
316
+
317
+ if (name === 'concerns') {
318
+ if (!flags.req || !flags.concern || !flags.disposition) {
319
+ stderr.write('[error] usage ledger: concerns add requires --req <req-id> --concern <slug> --disposition applied|waived\n');
320
+ return 2;
321
+ }
322
+ if (flags.disposition === 'waived' && !flags.reason) {
323
+ stderr.write('[error] usage ledger: concerns add --disposition waived requires --reason <text>\n');
324
+ return 2;
325
+ }
326
+ const entry = {
327
+ concern: String(flags.concern),
328
+ reqId: String(flags.req),
329
+ disposition: String(flags.disposition),
330
+ };
331
+ if (flags.reason) entry.reason = String(flags.reason);
332
+ const step = addEntry({ name: 'concerns', body, entry, now });
333
+ await saveLedger({ projectRoot, name: 'concerns', body: step.body });
334
+ stdout.write(`concern-ledger: added concern ${step.entry.id}\n`);
335
+ return 0;
336
+ }
337
+
338
+ // probes
339
+ if (!flags.req || !flags.finding) {
340
+ stderr.write('[error] usage ledger: probes add requires --req <req-id> --finding <text>\n');
341
+ return 2;
342
+ }
343
+ const entry = {
344
+ reqId: String(flags.req),
345
+ finding: String(flags.finding),
346
+ severity: flags.severity ? String(flags.severity) : 'medium',
347
+ };
348
+ const step = addEntry({ name: 'probes', body, entry, now });
349
+ await saveLedger({ projectRoot, name: 'probes', body: step.body });
350
+ stdout.write(`probe-ledger: added probe ${step.entry.id}\n`);
351
+ return 0;
352
+ }
353
+
354
+ async function runResolve({ projectRoot, name, positionals, flags, stdout, stderr }) {
355
+ if (positionals.length !== 1) {
356
+ stderr.write('[error] usage ledger: resolve expects exactly one <id>\n');
357
+ return 2;
358
+ }
359
+ const id = Number(positionals[0]);
360
+ if (!Number.isInteger(id) || id < 1) {
361
+ stderr.write(`[error] usage ledger: resolve id must be a positive integer, got '${positionals[0]}'\n`);
362
+ return 2;
363
+ }
364
+ const body = await loadLedger({ projectRoot, name });
365
+ const now = new Date().toISOString();
366
+ /** @type {Record<string, unknown>} */
367
+ const patch = {};
368
+ if (flags.answer) patch.answer = String(flags.answer);
369
+ if (flags.reason) patch.reason = String(flags.reason);
370
+ if (flags.answer) patch.answeredAt = now;
371
+ const step = resolveEntry({ name, body, id, patch, now });
372
+ await saveLedger({ projectRoot, name, body: step.body });
373
+ stdout.write(`${name}-ledger: resolved ${id}\n`);
374
+ return 0;
375
+ }
376
+
377
+ /**
378
+ * Parse repeated `--option letter:text` into option records. Returns
379
+ * `{ value }` on success or `{ error }` on any malformed input.
380
+ *
381
+ * @param {unknown} raw
382
+ * @returns {{ value: Array<{ letter: string, text: string }> } | { error: string }}
383
+ */
384
+ export function parseOptionFlags(raw) {
385
+ if (!raw || !Array.isArray(raw) || raw.length === 0) {
386
+ return { error: "decisions add requires at least one --option 'a:First option'" };
387
+ }
388
+ const value = [];
389
+ for (const s of raw) {
390
+ if (typeof s !== 'string') return { error: `--option value must be a string, got ${typeof s}` };
391
+ const sep = s.indexOf(':');
392
+ if (sep < 1) return { error: `--option must be 'letter:text', got '${s}'` };
393
+ const letter = s.slice(0, sep).trim();
394
+ const text = s.slice(sep + 1).trim();
395
+ if (!letter || !text) return { error: `--option must be 'letter:text', got '${s}'` };
396
+ value.push({ letter, text });
397
+ }
398
+ return { value };
399
+ }