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.
- package/CHANGELOG.md +211 -0
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +21 -0
- package/rcf/code-nodes/cn-726.json +17 -0
- package/rcf/code-nodes/cn-727.json +14 -0
- package/rcf/code-nodes/cn-728.json +21 -0
- package/rcf/code-nodes/cn-729.json +18 -0
- package/rcf/code-nodes/cn-730.json +14 -0
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +37 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +2 -1
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +25 -0
- package/rcf/requirements/req-175.json +25 -0
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
- package/rcf/tacs/tac-4122-define-gates.json +25 -0
- package/rcf/tacs/tac-4123-define-readiness.json +25 -0
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +67 -0
- package/rcf/test-suites/ts-216.json +78 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/user-stories/us-15601.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17401.json +58 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/releases/releases.yaml +21 -1
- package/src/cli/feedback.js +126 -10
- package/src/cli/help.js +11 -0
- package/src/cli/ledger.js +399 -0
- package/src/cli/readiness.js +280 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +232 -0
- package/src/define/ledgers.js +513 -0
- package/src/feedback/redact.js +105 -19
- package/src/query/delta.js +276 -0
- package/src/query/gates.js +832 -0
- package/src/query/index.js +36 -0
- package/src/query/readiness.js +333 -0
- package/src/server/index.js +37 -0
- package/src/server/routes.js +65 -3
- package/src/view/doc-renderers/helpers.js +2 -2
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +347 -28
- package/src/view/index.js +14 -1
- package/src/view/live-client.js +22 -2
- package/src/view/product-map.js +751 -0
- package/src/view/style.css +378 -0
package/src/cli/feedback.js
CHANGED
|
@@ -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 }`.
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
+
}
|