rcf-lite 0.28.2 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +317 -0
- package/bin/rcf.js +8 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/define-draft-shapes.md +89 -0
- package/guidance/define-intake.md +97 -0
- package/guidance/define-intent.md +107 -0
- package/guidance/discover-prototype.md +67 -0
- package/guidance/discovery-prototypes.md +69 -0
- package/guidance/harness-template.md +34 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +34 -0
- package/guidance/manifest.json +9 -2
- 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/adrs/adr-4124-define-freeze-verb.json +30 -0
- package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
- package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
- package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
- package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
- package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
- package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
- package/rcf/adrs/adr-4134-index-json-route.json +35 -0
- package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
- package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -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 +37 -0
- package/rcf/code-nodes/cn-726.json +25 -0
- package/rcf/code-nodes/cn-727.json +23 -0
- package/rcf/code-nodes/cn-728.json +37 -0
- package/rcf/code-nodes/cn-729.json +26 -0
- package/rcf/code-nodes/cn-730.json +24 -0
- package/rcf/code-nodes/cn-731.json +20 -0
- package/rcf/code-nodes/cn-732.json +14 -0
- package/rcf/code-nodes/cn-733.json +14 -0
- package/rcf/code-nodes/cn-734.json +14 -0
- package/rcf/code-nodes/cn-735.json +20 -0
- package/rcf/code-nodes/cn-736.json +14 -0
- package/rcf/code-nodes/cn-737.json +14 -0
- package/rcf/code-nodes/cn-738.json +14 -0
- package/rcf/code-nodes/cn-739.json +18 -0
- package/rcf/code-nodes/cn-740.json +19 -0
- package/rcf/code-nodes/cn-741.json +16 -0
- package/rcf/code-nodes/cn-742.json +16 -0
- package/rcf/code-nodes/cn-743.json +15 -0
- package/rcf/code-nodes/cn-744.json +22 -0
- package/rcf/code-nodes/cn-745.json +16 -0
- package/rcf/code-nodes/cn-746.json +14 -0
- package/rcf/code-nodes/cn-747.json +18 -0
- package/rcf/code-nodes/cn-748.json +16 -0
- package/rcf/code-nodes/cn-749.json +16 -0
- package/rcf/code-nodes/cn-750.json +15 -0
- package/rcf/code-nodes/cn-751.json +18 -0
- package/rcf/code-nodes/cn-752.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 +45 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/fbs/fbs-198.json +42 -0
- package/rcf/fbs/fbs-199.json +59 -0
- package/rcf/fbs/fbs-200.json +46 -0
- package/rcf/fbs/fbs-201.json +49 -0
- package/rcf/fbs/fbs-202.json +110 -0
- package/rcf/fbs/fbs-203.json +100 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +3 -2
- 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 +31 -0
- package/rcf/requirements/req-175.json +31 -0
- package/rcf/requirements/req-176.json +25 -0
- package/rcf/requirements/req-180.json +31 -0
- package/rcf/requirements/req-181.json +31 -0
- package/rcf/requirements/req-182.json +25 -0
- package/rcf/requirements/req-186.json +25 -0
- package/rcf/requirements/req-187.json +31 -0
- package/rcf/requirements/req-188.json +25 -0
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +35 -0
- package/rcf/tacs/tac-4122-define-gates.json +61 -0
- package/rcf/tacs/tac-4123-define-readiness.json +61 -0
- package/rcf/tacs/tac-4124-define-freeze.json +28 -0
- package/rcf/tacs/tac-4127-readiness-view.json +49 -0
- package/rcf/tacs/tac-4128-define-guidance.json +53 -0
- package/rcf/tacs/tac-4129-define-questions.json +45 -0
- package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
- package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
- package/rcf/tacs/tac-4132-id-lookup.json +42 -0
- package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
- package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
- package/rcf/test-suites/ts-016.json +3 -3
- 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 +211 -0
- package/rcf/test-suites/ts-216.json +94 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/test-suites/ts-218.json +78 -0
- package/rcf/test-suites/ts-219.json +70 -0
- package/rcf/test-suites/ts-220.json +78 -0
- package/rcf/test-suites/ts-221.json +78 -0
- package/rcf/test-suites/ts-222.json +100 -0
- package/rcf/test-suites/ts-223.json +94 -0
- package/rcf/test-suites/ts-224.json +70 -0
- package/rcf/test-suites/ts-225.json +78 -0
- package/rcf/test-suites/ts-226.json +158 -0
- package/rcf/test-suites/ts-227.json +38 -0
- package/rcf/test-suites/ts-228.json +86 -0
- package/rcf/test-suites/ts-229.json +83 -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-17302.json +50 -0
- package/rcf/user-stories/us-17401.json +70 -0
- package/rcf/user-stories/us-17402.json +55 -0
- package/rcf/user-stories/us-17403.json +60 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/rcf/user-stories/us-17502.json +60 -0
- package/rcf/user-stories/us-17503.json +65 -0
- package/rcf/user-stories/us-17601.json +58 -0
- package/rcf/user-stories/us-18001.json +55 -0
- package/rcf/user-stories/us-18002.json +35 -0
- package/rcf/user-stories/us-18101.json +35 -0
- package/rcf/user-stories/us-18202.json +40 -0
- package/rcf/user-stories/us-18601.json +55 -0
- package/rcf/user-stories/us-18602.json +50 -0
- package/rcf/user-stories/us-18603.json +45 -0
- package/rcf/user-stories/us-18701.json +50 -0
- package/rcf/user-stories/us-18702.json +50 -0
- package/rcf/user-stories/us-18801.json +45 -0
- package/rcf/user-stories/us-204.json +59 -0
- package/rcf/user-stories/us-205.json +64 -0
- package/rcf/user-stories/us-206.json +54 -0
- package/releases/releases.yaml +31 -1
- package/src/cli/feedback.js +126 -10
- package/src/cli/freeze.js +730 -0
- package/src/cli/help.js +23 -0
- package/src/cli/ledger.js +615 -0
- package/src/cli/questions.js +288 -0
- package/src/cli/readiness.js +498 -0
- package/src/cli/view.js +29 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +247 -0
- package/src/define/ledgers.js +712 -0
- package/src/feedback/redact.js +105 -19
- package/src/intake/index.js +1 -1
- package/src/intake/orchestrator.js +117 -0
- package/src/mcp/tools.js +354 -0
- package/src/query/delta.js +282 -0
- package/src/query/gates.js +1107 -0
- package/src/query/index.js +50 -0
- package/src/query/questions.js +438 -0
- package/src/query/readiness.js +574 -0
- package/src/server/index.js +60 -1
- package/src/server/routes.js +133 -4
- package/src/setup/identity-seed.js +17 -0
- package/src/view/build-dag.js +563 -0
- package/src/view/components/badge.js +41 -0
- package/src/view/components/diff.js +72 -0
- package/src/view/components/doc-row.js +62 -0
- package/src/view/components/empty-state.js +35 -0
- package/src/view/components/entity-selector.js +137 -0
- package/src/view/components/filter-bar.js +97 -0
- package/src/view/components/findings-list.js +49 -0
- package/src/view/components/index.js +12 -0
- package/src/view/components/pill.js +46 -0
- package/src/view/components/sub-tab-strip.js +43 -0
- package/src/view/components/toast.js +25 -0
- package/src/view/components-fixture.js +265 -0
- package/src/view/doc-renderers/architecture.js +304 -0
- package/src/view/doc-renderers/build.js +160 -0
- package/src/view/doc-renderers/helpers.js +2 -2
- package/src/view/doc-renderers/index.js +2 -0
- package/src/view/doc-renderers/prd.js +35 -6
- 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 +717 -300
- package/src/view/index.js +109 -3
- package/src/view/live-client.js +61 -8
- package/src/view/needs-work.js +79 -0
- package/src/view/page-init.js +2193 -0
- package/src/view/product-map.js +751 -0
- package/src/view/readiness/phrasebook.js +115 -0
- package/src/view/readiness/po-layer.js +288 -0
- package/src/view/readiness/question-adapter.js +329 -0
- package/src/view/readiness/stage-legend.js +71 -0
- package/src/view/readiness.js +505 -0
- package/src/view/style.css +2279 -266
- package/src/view/test-host/embed-client.js +121 -0
- package/src/view/test-host/fixture.js +183 -0
- package/src/view/test-host/lints.js +220 -0
- package/src/view/view-index.js +323 -0
|
@@ -0,0 +1,712 @@
|
|
|
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
|
+
if (e.resolvedBy !== undefined && typeof e.resolvedBy !== 'string') {
|
|
193
|
+
throw new LedgerError(`brief ${where}.resolvedBy must be a string when present.`, {
|
|
194
|
+
filePath, field: `${where}.resolvedBy`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
} else if (name === 'decisions') {
|
|
198
|
+
if (typeof e.question !== 'string' || e.question.length === 0) {
|
|
199
|
+
throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
|
|
200
|
+
filePath, field: `${where}.question`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (!Array.isArray(e.options) || e.options.length === 0) {
|
|
204
|
+
throw new LedgerError(`decisions ${where}.options must be a non-empty array.`, {
|
|
205
|
+
filePath, field: `${where}.options`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (let i = 0; i < e.options.length; i += 1) {
|
|
209
|
+
const opt = e.options[i];
|
|
210
|
+
if (
|
|
211
|
+
opt === null || typeof opt !== 'object' || Array.isArray(opt)
|
|
212
|
+
|| typeof (/** @type {any} */ (opt)).letter !== 'string'
|
|
213
|
+
|| typeof (/** @type {any} */ (opt)).text !== 'string'
|
|
214
|
+
) {
|
|
215
|
+
throw new LedgerError(
|
|
216
|
+
`decisions ${where}.options[${i}] must be { letter, text }.`,
|
|
217
|
+
{ filePath, field: `${where}.options[${i}]` },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (e.default !== null && e.default !== undefined && typeof e.default !== 'string') {
|
|
222
|
+
throw new LedgerError(
|
|
223
|
+
`decisions ${where}.default must be a letter string or null.`,
|
|
224
|
+
{ filePath, field: `${where}.default` },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (e.blocks !== undefined && typeof e.blocks !== 'string') {
|
|
228
|
+
throw new LedgerError(`decisions ${where}.blocks must be a string when present.`, {
|
|
229
|
+
filePath, field: `${where}.blocks`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} else if (name === 'concerns') {
|
|
233
|
+
if (typeof e.concern !== 'string' || e.concern.length === 0) {
|
|
234
|
+
throw new LedgerError(`concerns ${where}.concern must be non-empty.`, {
|
|
235
|
+
filePath, field: `${where}.concern`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
239
|
+
throw new LedgerError(`concerns ${where}.reqId must be non-empty.`, {
|
|
240
|
+
filePath, field: `${where}.reqId`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
typeof e.disposition !== 'string'
|
|
245
|
+
|| !CONCERN_DISPOSITIONS.includes(/** @type {any} */ (e.disposition))
|
|
246
|
+
) {
|
|
247
|
+
throw new LedgerError(
|
|
248
|
+
`concerns ${where}.disposition must be one of: ${CONCERN_DISPOSITIONS.join(', ')}.`,
|
|
249
|
+
{ filePath, field: `${where}.disposition` },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
} else if (name === 'probes') {
|
|
253
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
254
|
+
throw new LedgerError(`probes ${where}.reqId must be non-empty.`, {
|
|
255
|
+
filePath, field: `${where}.reqId`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (typeof e.finding !== 'string' || e.finding.length === 0) {
|
|
259
|
+
throw new LedgerError(`probes ${where}.finding must be non-empty.`, {
|
|
260
|
+
filePath, field: `${where}.finding`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
typeof e.severity !== 'string'
|
|
265
|
+
|| !PROBE_SEVERITIES.includes(/** @type {any} */ (e.severity))
|
|
266
|
+
) {
|
|
267
|
+
throw new LedgerError(
|
|
268
|
+
`probes ${where}.severity must be one of: ${PROBE_SEVERITIES.join(', ')}.`,
|
|
269
|
+
{ filePath, field: `${where}.severity` },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Validate a full ledger body (the shape a file holds).
|
|
277
|
+
*
|
|
278
|
+
* @param {LedgerName} name
|
|
279
|
+
* @param {unknown} raw
|
|
280
|
+
* @param {string} [filePath]
|
|
281
|
+
* @returns {Record<string, unknown[]>}
|
|
282
|
+
*/
|
|
283
|
+
export function validateLedger(name, raw, filePath) {
|
|
284
|
+
const cfg = LEDGER_CONFIG[name];
|
|
285
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { filePath, code: 'usage' });
|
|
286
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
287
|
+
throw new LedgerError(`${name} ledger must be a JSON object.`, {
|
|
288
|
+
filePath, field: '(root)',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const body = /** @type {Record<string, unknown>} */ (raw);
|
|
292
|
+
const list = body[cfg.arrayKey];
|
|
293
|
+
if (!Array.isArray(list)) {
|
|
294
|
+
throw new LedgerError(
|
|
295
|
+
`${name} ledger '${cfg.arrayKey}' must be an array.`,
|
|
296
|
+
{ filePath, field: cfg.arrayKey },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
301
|
+
const entry = list[i];
|
|
302
|
+
validateEntry(name, entry, filePath, `${cfg.arrayKey}[${i}]`);
|
|
303
|
+
const id = /** @type {any} */ (entry).id;
|
|
304
|
+
if (seen.has(id)) {
|
|
305
|
+
throw new LedgerError(
|
|
306
|
+
`${name} ledger has duplicate id ${id}.`,
|
|
307
|
+
{ filePath, field: `${cfg.arrayKey}[${i}].id` },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
seen.add(id);
|
|
311
|
+
}
|
|
312
|
+
return /** @type {Record<string, unknown[]>} */ (body);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load a ledger. Returns the empty ledger when the file is absent;
|
|
317
|
+
* throws `LedgerError` on parse / schema failure.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} args
|
|
320
|
+
* @param {string} args.projectRoot
|
|
321
|
+
* @param {LedgerName} args.name
|
|
322
|
+
* @returns {Promise<Record<string, unknown[]>>}
|
|
323
|
+
*/
|
|
324
|
+
export async function loadLedger({ projectRoot, name }) {
|
|
325
|
+
const relPath = ledgerRelPath(name);
|
|
326
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
327
|
+
let raw;
|
|
328
|
+
try {
|
|
329
|
+
raw = await readFile(filePath, 'utf8');
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
332
|
+
return emptyLedger(name);
|
|
333
|
+
}
|
|
334
|
+
throw new LedgerError(
|
|
335
|
+
`Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
|
|
336
|
+
{ filePath: relPath, code: 'ioFailure' },
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
let parsed;
|
|
340
|
+
try {
|
|
341
|
+
parsed = JSON.parse(raw);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
throw new LedgerError(
|
|
344
|
+
`${relPath} parse failed: ${/** @type {Error} */ (err).message}`,
|
|
345
|
+
{ filePath: relPath, code: 'parseFailure' },
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return validateLedger(name, parsed, relPath);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Save a ledger, validating first.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} args
|
|
355
|
+
* @param {string} args.projectRoot
|
|
356
|
+
* @param {LedgerName} args.name
|
|
357
|
+
* @param {Record<string, unknown>} args.body
|
|
358
|
+
* @returns {Promise<{ filePath: string }>}
|
|
359
|
+
*/
|
|
360
|
+
export async function saveLedger({ projectRoot, name, body }) {
|
|
361
|
+
const validated = validateLedger(name, body, ledgerRelPath(name));
|
|
362
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
363
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
364
|
+
await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
|
|
365
|
+
return { filePath };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Highest currently-used id on a ledger; 0 when empty.
|
|
370
|
+
*
|
|
371
|
+
* @param {LedgerName} name
|
|
372
|
+
* @param {Record<string, unknown[]>} body
|
|
373
|
+
* @returns {number}
|
|
374
|
+
*/
|
|
375
|
+
export function nextIdFor(name, body) {
|
|
376
|
+
const cfg = LEDGER_CONFIG[name];
|
|
377
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
378
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
379
|
+
let max = 0;
|
|
380
|
+
for (const e of list) {
|
|
381
|
+
const id = Number(e?.id);
|
|
382
|
+
if (Number.isFinite(id) && id > max) max = id;
|
|
383
|
+
}
|
|
384
|
+
return max + 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Append an entry to a ledger. Mints the next id, stamps `addedAt`,
|
|
389
|
+
* defaults `status: 'open'`, and validates before returning the
|
|
390
|
+
* updated body. Pure over its inputs (does not write to disk); the
|
|
391
|
+
* CLI calls `saveLedger` with the result.
|
|
392
|
+
*
|
|
393
|
+
* @param {object} args
|
|
394
|
+
* @param {LedgerName} args.name
|
|
395
|
+
* @param {Record<string, unknown[]>} args.body
|
|
396
|
+
* @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
|
|
397
|
+
* @param {string} [args.now] - ISO timestamp; defaults to Date.now()
|
|
398
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
399
|
+
*/
|
|
400
|
+
export function addEntry({ name, body, entry, now }) {
|
|
401
|
+
const cfg = LEDGER_CONFIG[name];
|
|
402
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
403
|
+
const id = nextIdFor(name, body);
|
|
404
|
+
const full = {
|
|
405
|
+
id,
|
|
406
|
+
...entry,
|
|
407
|
+
addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
|
|
408
|
+
status: entry.status ?? 'open',
|
|
409
|
+
};
|
|
410
|
+
const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
|
|
411
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
412
|
+
return { body: nextBody, entry: full };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Mark an entry `resolved` and stamp `resolvedAt`. Throws `LedgerError`
|
|
417
|
+
* (code `usage`) when the id is unknown. Idempotent-ish: resolving an
|
|
418
|
+
* already-resolved entry refreshes `resolvedAt`.
|
|
419
|
+
*
|
|
420
|
+
* @param {object} args
|
|
421
|
+
* @param {LedgerName} args.name
|
|
422
|
+
* @param {Record<string, unknown[]>} args.body
|
|
423
|
+
* @param {number} args.id
|
|
424
|
+
* @param {Record<string, unknown>} [args.patch] - extra fields (e.g. answer, resolvedBy)
|
|
425
|
+
* @param {string} [args.now]
|
|
426
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
427
|
+
*/
|
|
428
|
+
export function resolveEntry({ name, body, id, patch = {}, now }) {
|
|
429
|
+
const cfg = LEDGER_CONFIG[name];
|
|
430
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
431
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
432
|
+
const idx = list.findIndex((e) => Number(e?.id) === Number(id));
|
|
433
|
+
if (idx < 0) {
|
|
434
|
+
throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
|
|
435
|
+
filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const updated = {
|
|
439
|
+
...list[idx],
|
|
440
|
+
...patch,
|
|
441
|
+
status: 'resolved',
|
|
442
|
+
resolvedAt: patch.resolvedAt ?? now ?? new Date().toISOString(),
|
|
443
|
+
};
|
|
444
|
+
const nextList = [...list];
|
|
445
|
+
nextList[idx] = updated;
|
|
446
|
+
const nextBody = { ...body, [cfg.arrayKey]: nextList };
|
|
447
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
448
|
+
return { body: nextBody, entry: updated };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const RESOLVED_BY_GRAMMAR_RE = /^(REQ-\d+|TAD\.entity:.+|PRD\.user:.+|TAD\.system:.+|TAC-\d+(?:-[A-Za-z0-9-]+)?|omitted:.+)$/;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* True when `pointer` matches the closed grammar for a `resolvedBy`
|
|
455
|
+
* value. Shape-only; does NOT verify that the pointed-to id or name
|
|
456
|
+
* resolves against a tree (that is D2's `skeleton:resolvedBy` check).
|
|
457
|
+
*
|
|
458
|
+
* @param {string} pointer
|
|
459
|
+
* @returns {boolean}
|
|
460
|
+
*/
|
|
461
|
+
export function isResolvedByGrammar(pointer) {
|
|
462
|
+
if (typeof pointer !== 'string') return false;
|
|
463
|
+
return RESOLVED_BY_GRAMMAR_RE.test(pointer.trim());
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const KIND_MARKER_RE = /^\[([^\]]+)\]\s*/;
|
|
467
|
+
const SOURCE_MARKER_RE = /\s*\(source:\s*([^)]+)\)\s*$/;
|
|
468
|
+
const QUESTION_PREFIX_RE = /^(TBC|TBD|Open|Question)\b[:.]?\s*/i;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Parse `--from <file>` content into brief-statement drafts: one entry
|
|
472
|
+
* per non-empty line. Each line optionally carries a leading `[kind]`
|
|
473
|
+
* marker and a trailing `(source: ...)` marker. A line without
|
|
474
|
+
* `[kind]` takes `opts.kind` (default `capability`); a line without
|
|
475
|
+
* `(source: ...)` takes `opts.source` or no source. Heuristics kind a
|
|
476
|
+
* line `openQuestion` when it ends with `?` or begins with `TBC`,
|
|
477
|
+
* `TBD`, `Open:` or `Question:`, unless a `[kind]` marker says
|
|
478
|
+
* otherwise. Unknown `[kind]` values raise `LedgerError` (code
|
|
479
|
+
* `usage`) before any write.
|
|
480
|
+
*
|
|
481
|
+
* @param {string} content
|
|
482
|
+
* @param {object} [opts]
|
|
483
|
+
* @param {string} [opts.kind] default kind for every unmarked line
|
|
484
|
+
* @param {string} [opts.source] default source for every unmarked line
|
|
485
|
+
* @returns {Array<{ text: string, kind: string, source?: string }>}
|
|
486
|
+
*/
|
|
487
|
+
export function parseBriefFromFile(content, opts = {}) {
|
|
488
|
+
const defaultKind = opts.kind ?? 'capability';
|
|
489
|
+
/** @type {Array<{ text: string, kind: string, source?: string }>} */
|
|
490
|
+
const drafts = [];
|
|
491
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
492
|
+
const trimmed = raw.trim();
|
|
493
|
+
if (!trimmed) continue;
|
|
494
|
+
let line = trimmed
|
|
495
|
+
.replace(/^[-*]\s+/, '')
|
|
496
|
+
.replace(/^\d+[.)]\s+/, '');
|
|
497
|
+
if (!line) continue;
|
|
498
|
+
|
|
499
|
+
// Trailing (source: ...) marker.
|
|
500
|
+
let source = opts.source;
|
|
501
|
+
const srcMatch = line.match(SOURCE_MARKER_RE);
|
|
502
|
+
if (srcMatch) {
|
|
503
|
+
source = srcMatch[1].trim();
|
|
504
|
+
line = line.slice(0, srcMatch.index).trimEnd();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Leading [kind] marker.
|
|
508
|
+
let kind = null;
|
|
509
|
+
const kindMatch = line.match(KIND_MARKER_RE);
|
|
510
|
+
if (kindMatch) {
|
|
511
|
+
kind = kindMatch[1].trim();
|
|
512
|
+
line = line.slice(kindMatch[0].length).trim();
|
|
513
|
+
if (!BRIEF_KINDS.includes(/** @type {any} */ (kind))) {
|
|
514
|
+
throw new LedgerError(
|
|
515
|
+
`brief --from: unknown kind '${kind}' (expected one of: ${BRIEF_KINDS.join(', ')})`,
|
|
516
|
+
{ field: 'kind', code: 'usage' },
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!line) continue;
|
|
522
|
+
|
|
523
|
+
// Heuristics for openQuestion when no [kind] marker was given.
|
|
524
|
+
if (!kind) {
|
|
525
|
+
if (line.endsWith('?') || QUESTION_PREFIX_RE.test(line)) {
|
|
526
|
+
kind = 'openQuestion';
|
|
527
|
+
} else {
|
|
528
|
+
kind = defaultKind;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const entry = { text: line, kind };
|
|
533
|
+
if (source) entry.source = source;
|
|
534
|
+
drafts.push(entry);
|
|
535
|
+
}
|
|
536
|
+
return drafts;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Parse a `resolvedBy` pointer against the walker tree. The six
|
|
541
|
+
* accepted forms are `REQ-nnn`, `TAD.entity:<name>`, `PRD.user:<name>`,
|
|
542
|
+
* `TAD.system:<name>`, `TAC-nnnn`, and `omitted:<reason>`. Resolution
|
|
543
|
+
* checks the pointed-to id or name actually exists in the tree
|
|
544
|
+
* (except for `omitted:`, which always passes once the reason is
|
|
545
|
+
* non-empty).
|
|
546
|
+
*
|
|
547
|
+
* @param {string} pointer
|
|
548
|
+
* @param {object} tree walker tree (TreeModel-like)
|
|
549
|
+
* @returns {{ ok: true, kind: string, target: string } | { ok: false, why: string }}
|
|
550
|
+
*/
|
|
551
|
+
export function parseResolvedBy(pointer, tree) {
|
|
552
|
+
if (typeof pointer !== 'string' || pointer.length === 0) {
|
|
553
|
+
return { ok: false, why: 'pointer must be a non-empty string' };
|
|
554
|
+
}
|
|
555
|
+
const p = pointer.trim();
|
|
556
|
+
|
|
557
|
+
// omitted:<reason>
|
|
558
|
+
const omittedMatch = p.match(/^omitted:(.+)$/);
|
|
559
|
+
if (omittedMatch) {
|
|
560
|
+
const reason = omittedMatch[1].trim();
|
|
561
|
+
if (!reason) return { ok: false, why: 'omitted: pointer needs a reason' };
|
|
562
|
+
return { ok: true, kind: 'omitted', target: reason };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// REQ-nnn
|
|
566
|
+
if (/^REQ-\d+$/.test(p)) {
|
|
567
|
+
const found = (tree?.requirements ?? []).some((r) => r?.reqId === p);
|
|
568
|
+
if (!found) return { ok: false, why: 'pointer does not resolve' };
|
|
569
|
+
return { ok: true, kind: 'req', target: p };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// TAC-nnnn (short form, resolves to a tree TAC whose id starts
|
|
573
|
+
// with that prefix) or TAC-nnnn-<slug> (exact-match long form).
|
|
574
|
+
// Codex review 2026-10-02: an unanchored /^TAC-\d+/ let arbitrary
|
|
575
|
+
// suffixes (TAC-4130-made-up) pass as long as the numeric prefix
|
|
576
|
+
// matched an existing TAC; anchor the regex and gate the prefix
|
|
577
|
+
// fallback to the short form only.
|
|
578
|
+
const tacMatch = p.match(/^TAC-\d+(?:-[A-Za-z0-9-]+)?$/);
|
|
579
|
+
if (tacMatch) {
|
|
580
|
+
const tacs = tree?.tacs ?? [];
|
|
581
|
+
const isShortForm = /^TAC-\d+$/.test(p);
|
|
582
|
+
const found = tacs.some((t) => {
|
|
583
|
+
if (!t?.tacId) return false;
|
|
584
|
+
if (t.tacId === p) return true;
|
|
585
|
+
if (!isShortForm) return false;
|
|
586
|
+
return t.tacId.split('-').slice(0, 2).join('-') === p;
|
|
587
|
+
});
|
|
588
|
+
if (!found) return { ok: false, why: 'pointer does not resolve' };
|
|
589
|
+
return { ok: true, kind: 'tac', target: p };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// TAD.entity:<name>
|
|
593
|
+
const entityMatch = p.match(/^TAD\.entity:(.+)$/);
|
|
594
|
+
if (entityMatch) {
|
|
595
|
+
const name = entityMatch[1].trim();
|
|
596
|
+
if (!name) return { ok: false, why: 'TAD.entity: pointer needs a name' };
|
|
597
|
+
const entities = tree?.tad?.dataArchitecture?.coreEntities ?? [];
|
|
598
|
+
const found = Array.isArray(entities)
|
|
599
|
+
&& entities.some((e) => (typeof e === 'string' ? e : e?.name) === name);
|
|
600
|
+
if (!found) return { ok: false, why: 'pointer does not resolve' };
|
|
601
|
+
return { ok: true, kind: 'tadEntity', target: name };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// PRD.user:<name>
|
|
605
|
+
const userMatch = p.match(/^PRD\.user:(.+)$/);
|
|
606
|
+
if (userMatch) {
|
|
607
|
+
const name = userMatch[1].trim();
|
|
608
|
+
if (!name) return { ok: false, why: 'PRD.user: pointer needs a name' };
|
|
609
|
+
// Codex review 2026-10-02: the production PRD schema uses
|
|
610
|
+
// `targetUsers`; keep `users` as a fallback for the test
|
|
611
|
+
// fixtures but prefer the real path.
|
|
612
|
+
const users = tree?.prd?.targetUsers ?? tree?.prd?.users ?? [];
|
|
613
|
+
const found = Array.isArray(users)
|
|
614
|
+
&& users.some((u) => (typeof u === 'string' ? u : u?.name) === name);
|
|
615
|
+
if (!found) return { ok: false, why: 'pointer does not resolve' };
|
|
616
|
+
return { ok: true, kind: 'prdUser', target: name };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// TAD.system:<name>
|
|
620
|
+
const systemMatch = p.match(/^TAD\.system:(.+)$/);
|
|
621
|
+
if (systemMatch) {
|
|
622
|
+
const name = systemMatch[1].trim();
|
|
623
|
+
if (!name) return { ok: false, why: 'TAD.system: pointer needs a name' };
|
|
624
|
+
// Codex review 2026-10-02: the production TAD schema puts
|
|
625
|
+
// externalSystems inside integrationArchitecture; keep the
|
|
626
|
+
// top-level path as a fallback for the test fixtures.
|
|
627
|
+
const systems = tree?.tad?.integrationArchitecture?.externalSystems ?? tree?.tad?.externalSystems ?? [];
|
|
628
|
+
const found = Array.isArray(systems)
|
|
629
|
+
&& systems.some((s) => (typeof s === 'string' ? s : s?.name) === name);
|
|
630
|
+
if (!found) return { ok: false, why: 'pointer does not resolve' };
|
|
631
|
+
return { ok: true, kind: 'tadSystem', target: name };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { ok: false, why: 'pointer outside grammar (REQ-nnn, TAD.entity:<name>, PRD.user:<name>, TAD.system:<name>, TAC-nnnn, omitted:<reason>)' };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Patch a ledger entry in place. Field patches honour `add`-side
|
|
639
|
+
* validation by re-running `validateLedger` on the resulting body.
|
|
640
|
+
* Throws `LedgerError` (code `usage`) when the id is unknown. The
|
|
641
|
+
* entry's `id` and `addedAt` are never touched; `status`, `addedAt`
|
|
642
|
+
* and `resolvedAt` are passed through from the current entry unless
|
|
643
|
+
* explicitly patched.
|
|
644
|
+
*
|
|
645
|
+
* @param {object} args
|
|
646
|
+
* @param {LedgerName} args.name
|
|
647
|
+
* @param {Record<string, unknown[]>} args.body
|
|
648
|
+
* @param {number} args.id
|
|
649
|
+
* @param {Record<string, unknown>} args.patch
|
|
650
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
651
|
+
*/
|
|
652
|
+
export function updateEntry({ name, body, id, patch }) {
|
|
653
|
+
const cfg = LEDGER_CONFIG[name];
|
|
654
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
655
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
656
|
+
const idx = list.findIndex((e) => Number(e?.id) === Number(id));
|
|
657
|
+
if (idx < 0) {
|
|
658
|
+
throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
|
|
659
|
+
filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
const current = list[idx];
|
|
663
|
+
const next = { ...current };
|
|
664
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
665
|
+
if (value === undefined) continue;
|
|
666
|
+
if (key === 'id' || key === 'addedAt') continue;
|
|
667
|
+
next[key] = value;
|
|
668
|
+
}
|
|
669
|
+
const nextList = [...list];
|
|
670
|
+
nextList[idx] = next;
|
|
671
|
+
const nextBody = { ...body, [cfg.arrayKey]: nextList };
|
|
672
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
673
|
+
return { body: nextBody, entry: next };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Load every ledger the project has actually authored, keyed by name.
|
|
678
|
+
*
|
|
679
|
+
* A project that has never authored a given ledger contributes no
|
|
680
|
+
* `<name>` entry to the returned bundle, so `computeDelta` does not
|
|
681
|
+
* record a `ledger:<name>` docHash for absent ledgers (slice-2 P3
|
|
682
|
+
* resolution: this loader agrees with the `LedgerBundle` typedef on
|
|
683
|
+
* `computeDelta` that "if a project has not authored any decisions,
|
|
684
|
+
* the caller passes no `decisions` key and the delta records no
|
|
685
|
+
* `ledger:decisions` docHash"). Absent-file detection is a `stat` on
|
|
686
|
+
* the ledger path; an ENOENT skips the ledger without loading. A
|
|
687
|
+
* present-but-empty file is still loaded (an operator can `rcf
|
|
688
|
+
* define ledger <name> list --json > <path>` a stub in place; empty
|
|
689
|
+
* ledgers are legal and DO contribute a hash).
|
|
690
|
+
*
|
|
691
|
+
* @param {object} args
|
|
692
|
+
* @param {string} args.projectRoot
|
|
693
|
+
* @returns {Promise<import('../query/delta.js').LedgerBundle>}
|
|
694
|
+
*/
|
|
695
|
+
export async function loadAllLedgers({ projectRoot }) {
|
|
696
|
+
/** @type {import('../query/delta.js').LedgerBundle} */
|
|
697
|
+
const bundle = {};
|
|
698
|
+
for (const name of LEDGER_NAMES) {
|
|
699
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
700
|
+
try {
|
|
701
|
+
// eslint-disable-next-line no-await-in-loop
|
|
702
|
+
await stat(filePath);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
|
|
705
|
+
// Any other stat error (permission etc.) surfaces via loadLedger.
|
|
706
|
+
}
|
|
707
|
+
// eslint-disable-next-line no-await-in-loop
|
|
708
|
+
const body = await loadLedger({ projectRoot, name });
|
|
709
|
+
bundle[name] = body;
|
|
710
|
+
}
|
|
711
|
+
return bundle;
|
|
712
|
+
}
|