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
package/src/mcp/tools.js
CHANGED
|
@@ -43,6 +43,14 @@ import {
|
|
|
43
43
|
} from '../query/index.js';
|
|
44
44
|
import { assembleBundle } from '../build/index.js';
|
|
45
45
|
import { hasAgentMarker, SETUP_FUNNEL_INSTRUCTION } from '../setup/agent-setup.js';
|
|
46
|
+
import { readFile } from 'node:fs/promises';
|
|
47
|
+
import { join } from 'node:path';
|
|
48
|
+
import { computeReadiness } from '../query/readiness.js';
|
|
49
|
+
import { computeQuestions, PERSONAS as QUESTIONS_PERSONAS } from '../query/questions.js';
|
|
50
|
+
import { loadFreezeRecord } from '../define/freeze-record.js';
|
|
51
|
+
import { loadAllLedgers, LedgerError } from '../define/ledgers.js';
|
|
52
|
+
import { STAGE_ALIASES } from '../query/gates.js';
|
|
53
|
+
import { main as ledgerCliMain } from '../cli/ledger.js';
|
|
46
54
|
|
|
47
55
|
// ---------------------------------------------------------------------------
|
|
48
56
|
// Shared output-schema fragments
|
|
@@ -448,6 +456,93 @@ const BUILD_OUTPUT_SCHEMA = {
|
|
|
448
456
|
],
|
|
449
457
|
};
|
|
450
458
|
|
|
459
|
+
// REQ-186 (spec 2026-10-01 §1.2, §6): the questions object is wide
|
|
460
|
+
// and recursive in its context field. The output schema stays loose
|
|
461
|
+
// (an object with no additionalProperties: false) so the per-question
|
|
462
|
+
// context carries any of statement, suggestions, decision, reqId,
|
|
463
|
+
// profileField without a schema break per template addition.
|
|
464
|
+
const QUESTIONS_OUTPUT_SCHEMA = {
|
|
465
|
+
type: 'object',
|
|
466
|
+
properties: {
|
|
467
|
+
treeHash: { type: ['string', 'null'] },
|
|
468
|
+
persona: { type: 'string', enum: ['productOwner', 'engineer'] },
|
|
469
|
+
level: { type: 'string', enum: ['intent', 'build'] },
|
|
470
|
+
ok: { type: 'boolean' },
|
|
471
|
+
remaining: { type: 'integer' },
|
|
472
|
+
groups: {
|
|
473
|
+
type: 'array',
|
|
474
|
+
items: {
|
|
475
|
+
type: 'object',
|
|
476
|
+
properties: {
|
|
477
|
+
key: { type: 'string' },
|
|
478
|
+
label: { type: 'string' },
|
|
479
|
+
questionIds: { type: 'array', items: { type: 'string' } },
|
|
480
|
+
},
|
|
481
|
+
required: ['key', 'label', 'questionIds'],
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
questions: {
|
|
485
|
+
type: 'array',
|
|
486
|
+
items: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: {
|
|
489
|
+
id: { type: 'string' },
|
|
490
|
+
stage: { type: 'string' },
|
|
491
|
+
check: { type: 'string' },
|
|
492
|
+
itemId: { type: 'string' },
|
|
493
|
+
heading: { type: 'string' },
|
|
494
|
+
ask: { type: 'string' },
|
|
495
|
+
context: { type: 'object' },
|
|
496
|
+
answerKinds: { type: 'array', items: { type: 'string' } },
|
|
497
|
+
writeBack: {
|
|
498
|
+
type: 'array',
|
|
499
|
+
items: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
when: { type: 'string' },
|
|
503
|
+
command: { type: 'string' },
|
|
504
|
+
},
|
|
505
|
+
required: ['when', 'command'],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
blocks: { type: 'string', enum: ['intent', 'build'] },
|
|
509
|
+
},
|
|
510
|
+
required: ['id', 'stage', 'check', 'itemId', 'heading', 'ask', 'answerKinds', 'writeBack', 'blocks'],
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
optional: {
|
|
514
|
+
type: 'array',
|
|
515
|
+
items: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
properties: {
|
|
518
|
+
id: { type: 'string' },
|
|
519
|
+
ask: { type: 'string' },
|
|
520
|
+
blocks: { type: 'string', enum: ['build'] },
|
|
521
|
+
},
|
|
522
|
+
required: ['id', 'ask', 'blocks'],
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
engineer: {
|
|
526
|
+
type: 'object',
|
|
527
|
+
properties: {
|
|
528
|
+
blockers: { type: 'integer' },
|
|
529
|
+
nextAction: { type: ['object', 'null'] },
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
required: ['treeHash', 'persona', 'level', 'ok', 'remaining', 'groups', 'questions', 'optional'],
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// REQ-186: `rcf_define_readiness` returns the readiness envelope.
|
|
537
|
+
// Kept loose so new fields on readiness do not force an output-schema
|
|
538
|
+
// bump; the parity test over the verb's --json locks the actual shape.
|
|
539
|
+
const READINESS_OUTPUT_SCHEMA = { type: 'object' };
|
|
540
|
+
|
|
541
|
+
// REQ-186: `rcf_define_ledger` wraps the four-ledger handler.
|
|
542
|
+
// Return shape depends on the verb, so stays loose and the handler
|
|
543
|
+
// echoes the verb's own JSON envelope verbatim.
|
|
544
|
+
const LEDGER_OUTPUT_SCHEMA = { type: 'object' };
|
|
545
|
+
|
|
451
546
|
// ---------------------------------------------------------------------------
|
|
452
547
|
// Tool definitions (D5-D7, D17)
|
|
453
548
|
// ---------------------------------------------------------------------------
|
|
@@ -661,6 +756,73 @@ const DEFINITIONS = [
|
|
|
661
756
|
outputSchema: withErrorPayload(BUILD_OUTPUT_SCHEMA),
|
|
662
757
|
annotations: { readOnlyHint: true },
|
|
663
758
|
},
|
|
759
|
+
{
|
|
760
|
+
name: 'rcf_define_questions',
|
|
761
|
+
title: 'Persona question set for the intent-complete loop',
|
|
762
|
+
description: 'Returns the product-owner question set: one plain question per failing product-owner check with the exact write-back command that answers it. The compute is pure: no file under rcf/ is created or modified. Answers land through the write-back the question carries, then readiness recomputes; the loop ends when levels.intentComplete.ok is true. Method: a product-owner session starts with rcf_define_questions {persona: "productOwner"} and ends at the empty question set. {persona: "engineer"} returns the same shape over engineer blockers, but per-check question templates and write-backs for the engineer register are not part of DEFINE step 3 PR 2 (spec section 1.1 defers them to step 4); engineer-persona entries today carry empty answerKinds and writeBack and the harness runs the engineer verb by hand off the raw blocker.',
|
|
763
|
+
inputSchema: {
|
|
764
|
+
type: 'object',
|
|
765
|
+
properties: {
|
|
766
|
+
persona: { type: 'string', enum: ['productOwner', 'engineer'], description: 'Defaults to the register marker in rcf/.identity/profile.md, else productOwner.' },
|
|
767
|
+
stage: { type: 'string', description: 'Narrow to one stage (D1..D8 or short name).' },
|
|
768
|
+
limit: { type: 'integer', minimum: 0, description: 'Cap questions[] length; `remaining` still counts every matching item.' },
|
|
769
|
+
},
|
|
770
|
+
additionalProperties: false,
|
|
771
|
+
},
|
|
772
|
+
outputSchema: withErrorPayload(QUESTIONS_OUTPUT_SCHEMA),
|
|
773
|
+
annotations: { readOnlyHint: true },
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
name: 'rcf_define_readiness',
|
|
777
|
+
title: 'DEFINE stage-gate readiness view',
|
|
778
|
+
description: 'Returns the DEFINE readiness object: freezeable verdict, every stage/check state, levels (intentComplete, readyToBuild), personas (productOwner, engineer), delta, coverage and open decisions. The same object the Readiness tab consumes and the verb\'s --json prints. Writes nothing.',
|
|
779
|
+
inputSchema: {
|
|
780
|
+
type: 'object',
|
|
781
|
+
properties: {
|
|
782
|
+
level: { type: 'string', enum: ['intent', 'build'], description: 'Informational echo on the envelope _meta; does not filter the compute.' },
|
|
783
|
+
check: { type: 'string', description: 'Narrow the text summary to one stage (D1..D8 or short name). The structuredContent still carries every stage.' },
|
|
784
|
+
},
|
|
785
|
+
additionalProperties: false,
|
|
786
|
+
},
|
|
787
|
+
outputSchema: withErrorPayload(READINESS_OUTPUT_SCHEMA),
|
|
788
|
+
annotations: { readOnlyHint: true },
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: 'rcf_define_ledger',
|
|
792
|
+
title: 'Sidecar-ledger CRUD under rcf/define/',
|
|
793
|
+
description: 'Thin wrapper over the `rcf define ledger` handler. verb is one of add | update | resolve | list over one of the four ledgers (brief | decisions | concerns | probes). The command\'s JSON envelope is returned as structuredContent.',
|
|
794
|
+
inputSchema: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
properties: {
|
|
797
|
+
name: { type: 'string', enum: ['brief', 'decisions', 'concerns', 'probes'] },
|
|
798
|
+
verb: { type: 'string', enum: ['add', 'update', 'resolve', 'list'] },
|
|
799
|
+
id: { type: 'integer', minimum: 1, description: 'Required for update and resolve: the statement id.' },
|
|
800
|
+
text: { type: 'string' },
|
|
801
|
+
kind: { type: 'string' },
|
|
802
|
+
source: { type: 'string' },
|
|
803
|
+
from: { type: 'string', description: 'Path to a statement file (brief add --from).' },
|
|
804
|
+
resolvedBy: { type: 'string', description: 'Closed-grammar pointer: REQ-nnn | TAD.entity:<name> | PRD.user:<name> | TAD.system:<name> | TAC-nnnn | omitted:<reason>.' },
|
|
805
|
+
answer: { type: 'string' },
|
|
806
|
+
question: { type: 'string' },
|
|
807
|
+
option: { type: 'array', items: { type: 'string' }, description: 'Letter-prefixed decision options, e.g. ["a:accept", "b:reject"].' },
|
|
808
|
+
default: { type: 'string', description: 'Decision default option letter.' },
|
|
809
|
+
blocks: { type: 'string', description: 'Decision blocks selector, e.g. "brief:7".' },
|
|
810
|
+
scan: { type: 'boolean' },
|
|
811
|
+
findings: { type: 'string' },
|
|
812
|
+
dryRun: { type: 'boolean' },
|
|
813
|
+
reason: { type: 'string', description: 'resolve/add reason (concerns, probes).' },
|
|
814
|
+
req: { type: 'string', description: 'REQ-id for concerns and probes.' },
|
|
815
|
+
concern: { type: 'string', description: 'Concern slug (auth, retention...).' },
|
|
816
|
+
disposition: { type: 'string', enum: ['applied', 'waived'], description: 'Concerns disposition.' },
|
|
817
|
+
finding: { type: 'string', description: 'Probe finding text.' },
|
|
818
|
+
severity: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Probe finding severity.' },
|
|
819
|
+
},
|
|
820
|
+
required: ['name', 'verb'],
|
|
821
|
+
additionalProperties: false,
|
|
822
|
+
},
|
|
823
|
+
outputSchema: withErrorPayload(LEDGER_OUTPUT_SCHEMA),
|
|
824
|
+
annotations: { destructiveHint: false },
|
|
825
|
+
},
|
|
664
826
|
];
|
|
665
827
|
|
|
666
828
|
// ---------------------------------------------------------------------------
|
|
@@ -849,6 +1011,92 @@ function okResult(envelope) {
|
|
|
849
1011
|
};
|
|
850
1012
|
}
|
|
851
1013
|
|
|
1014
|
+
/**
|
|
1015
|
+
* Read `rcf/.identity/profile.md` for the questions / readiness
|
|
1016
|
+
* handlers. Returns null on any error (absence is a finding, not a
|
|
1017
|
+
* tool error).
|
|
1018
|
+
*/
|
|
1019
|
+
async function readProfileTextForMcp(projectRoot) {
|
|
1020
|
+
try {
|
|
1021
|
+
return await readFile(join(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Pick the ACTIVE register marker from the profile text. The template
|
|
1029
|
+
* mentions all three marker words in its explanation; the chosen
|
|
1030
|
+
* value is the bare word on a line of its own. The last such line
|
|
1031
|
+
* wins; a "Register: engineer" prose line is honoured via the
|
|
1032
|
+
* last-occurrence fallback. Matches the CLI's pickRegister.
|
|
1033
|
+
*/
|
|
1034
|
+
function pickRegisterForMcp(text) {
|
|
1035
|
+
if (typeof text !== 'string' || text.length === 0) return 'unstated';
|
|
1036
|
+
const markers = ['productOwner', 'engineer', 'unstated'];
|
|
1037
|
+
const lines = text.split(/\r?\n/);
|
|
1038
|
+
let bareLine = null;
|
|
1039
|
+
for (const raw of lines) {
|
|
1040
|
+
const trimmed = raw.trim();
|
|
1041
|
+
if (markers.includes(trimmed)) bareLine = trimmed;
|
|
1042
|
+
}
|
|
1043
|
+
if (bareLine) return /** @type {any} */ (bareLine);
|
|
1044
|
+
let bestIdx = -1;
|
|
1045
|
+
let best = null;
|
|
1046
|
+
for (const m of markers) {
|
|
1047
|
+
const idx = text.lastIndexOf(m);
|
|
1048
|
+
if (idx > bestIdx) { bestIdx = idx; best = m; }
|
|
1049
|
+
}
|
|
1050
|
+
return /** @type {any} */ (best) ?? 'unstated';
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Translate the MCP tool arguments for `rcf_define_ledger` into an
|
|
1055
|
+
* argv that `rcf define ledger` understands. Shared vocabulary and
|
|
1056
|
+
* `--json` always appended so the handler can round-trip the envelope.
|
|
1057
|
+
*/
|
|
1058
|
+
function buildLedgerArgvFromMcp(args) {
|
|
1059
|
+
const argv = [args.name, args.verb];
|
|
1060
|
+
if (args.verb === 'update' || args.verb === 'resolve') {
|
|
1061
|
+
argv.push(String(args.id ?? ''));
|
|
1062
|
+
}
|
|
1063
|
+
const pushFlag = (name, value) => {
|
|
1064
|
+
if (value === undefined || value === null) return;
|
|
1065
|
+
argv.push(`--${name}`);
|
|
1066
|
+
argv.push(String(value));
|
|
1067
|
+
};
|
|
1068
|
+
const pushBool = (name, value) => {
|
|
1069
|
+
if (value === true) argv.push(`--${name}`);
|
|
1070
|
+
else if (value === false) argv.push(`--no-${name}`);
|
|
1071
|
+
};
|
|
1072
|
+
pushFlag('text', args.text);
|
|
1073
|
+
pushFlag('kind', args.kind);
|
|
1074
|
+
pushFlag('source', args.source);
|
|
1075
|
+
pushFlag('from', args.from);
|
|
1076
|
+
pushFlag('resolved-by', args.resolvedBy);
|
|
1077
|
+
pushFlag('answer', args.answer);
|
|
1078
|
+
pushFlag('question', args.question);
|
|
1079
|
+
pushFlag('default', args.default);
|
|
1080
|
+
pushFlag('blocks', args.blocks);
|
|
1081
|
+
pushFlag('findings', args.findings);
|
|
1082
|
+
pushFlag('reason', args.reason);
|
|
1083
|
+
pushFlag('req', args.req);
|
|
1084
|
+
pushFlag('concern', args.concern);
|
|
1085
|
+
pushFlag('disposition', args.disposition);
|
|
1086
|
+
pushFlag('finding', args.finding);
|
|
1087
|
+
pushFlag('severity', args.severity);
|
|
1088
|
+
if (Array.isArray(args.option)) {
|
|
1089
|
+
for (const o of args.option) {
|
|
1090
|
+
argv.push('--option');
|
|
1091
|
+
argv.push(String(o));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (args.scan !== undefined) pushBool('scan', args.scan);
|
|
1095
|
+
if (args.dryRun) argv.push('--dry-run');
|
|
1096
|
+
argv.push('--json');
|
|
1097
|
+
return argv;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
852
1100
|
/**
|
|
853
1101
|
* Create the tool registry bound to one project root (D13: the root is
|
|
854
1102
|
* fixed for the process lifetime).
|
|
@@ -1119,6 +1367,112 @@ export function createToolRegistry({ projectRoot, log }) {
|
|
|
1119
1367
|
// (reconciliation carry 2, OQ-P7-8 posture).
|
|
1120
1368
|
return okResult({ ok: true, mode: 'bundle', ...bundle });
|
|
1121
1369
|
},
|
|
1370
|
+
|
|
1371
|
+
rcf_define_readiness: async (args) => {
|
|
1372
|
+
const { tree, errors } = await walkTree({ projectRoot });
|
|
1373
|
+
const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
|
|
1374
|
+
const validateErrors = [...errors, ...staleErrors];
|
|
1375
|
+
// Validate --check / --level inputs the same way the CLI does.
|
|
1376
|
+
let checkStage = null;
|
|
1377
|
+
if (typeof args.check === 'string') {
|
|
1378
|
+
if (args.check === 'all') checkStage = 'all';
|
|
1379
|
+
else if (STAGE_ALIASES[args.check]) checkStage = STAGE_ALIASES[args.check];
|
|
1380
|
+
else return usageErrorResult(`readiness: unknown --check '${args.check}'`);
|
|
1381
|
+
}
|
|
1382
|
+
const [freeze, ledgers, testPointers, profileText] = await Promise.all([
|
|
1383
|
+
loadFreezeRecord({ projectRoot }),
|
|
1384
|
+
loadAllLedgers({ projectRoot }),
|
|
1385
|
+
resolveTestPointers({ projectRoot, tree }),
|
|
1386
|
+
readProfileTextForMcp(projectRoot),
|
|
1387
|
+
]);
|
|
1388
|
+
const result = computeReadiness(tree, {
|
|
1389
|
+
freeze, ledgers, profile: undefined, profileText, testPointers, validateErrors,
|
|
1390
|
+
});
|
|
1391
|
+
const level = args.level ?? null;
|
|
1392
|
+
return okResult({ ...result, _meta: { checkStage, level } });
|
|
1393
|
+
},
|
|
1394
|
+
|
|
1395
|
+
rcf_define_questions: async (args) => {
|
|
1396
|
+
const persona = args.persona;
|
|
1397
|
+
if (persona !== undefined && !QUESTIONS_PERSONAS.includes(persona)) {
|
|
1398
|
+
return usageErrorResult(`questions: unknown persona '${persona}' (expected one of ${QUESTIONS_PERSONAS.join(' | ')})`);
|
|
1399
|
+
}
|
|
1400
|
+
let stageFilter = null;
|
|
1401
|
+
if (typeof args.stage === 'string') {
|
|
1402
|
+
if (!STAGE_ALIASES[args.stage]) {
|
|
1403
|
+
return usageErrorResult(`questions: unknown --stage '${args.stage}'`);
|
|
1404
|
+
}
|
|
1405
|
+
stageFilter = STAGE_ALIASES[args.stage];
|
|
1406
|
+
}
|
|
1407
|
+
const { tree, errors } = await walkTree({ projectRoot });
|
|
1408
|
+
const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
|
|
1409
|
+
const validateErrors = [...errors, ...staleErrors];
|
|
1410
|
+
const [freeze, ledgers, testPointers, profileText] = await Promise.all([
|
|
1411
|
+
loadFreezeRecord({ projectRoot }),
|
|
1412
|
+
loadAllLedgers({ projectRoot }),
|
|
1413
|
+
resolveTestPointers({ projectRoot, tree }),
|
|
1414
|
+
readProfileTextForMcp(projectRoot),
|
|
1415
|
+
]);
|
|
1416
|
+
let resolvedPersona = persona;
|
|
1417
|
+
if (resolvedPersona === undefined) {
|
|
1418
|
+
resolvedPersona = pickRegisterForMcp(profileText) === 'engineer' ? 'engineer' : 'productOwner';
|
|
1419
|
+
}
|
|
1420
|
+
const readiness = computeReadiness(tree, {
|
|
1421
|
+
freeze, ledgers, profile: undefined, profileText, testPointers, validateErrors,
|
|
1422
|
+
});
|
|
1423
|
+
const result = computeQuestions(readiness, {
|
|
1424
|
+
tree, ledgers, profileText, persona: resolvedPersona,
|
|
1425
|
+
});
|
|
1426
|
+
let filtered = result.questions;
|
|
1427
|
+
if (stageFilter) filtered = filtered.filter((q) => q.stage === stageFilter);
|
|
1428
|
+
const remaining = filtered.length;
|
|
1429
|
+
if (Number.isInteger(args.limit) && args.limit >= 0) {
|
|
1430
|
+
filtered = filtered.slice(0, args.limit);
|
|
1431
|
+
}
|
|
1432
|
+
const groupMap = new Map();
|
|
1433
|
+
for (const q of filtered) {
|
|
1434
|
+
const key = q.context?.statement?.source ?? q.context?.sourceSpan ?? 'tree';
|
|
1435
|
+
const existing = groupMap.get(key) ?? { key, label: result.groups.find((g) => g.key === key)?.label ?? key, questionIds: [] };
|
|
1436
|
+
existing.questionIds.push(q.id);
|
|
1437
|
+
groupMap.set(key, existing);
|
|
1438
|
+
}
|
|
1439
|
+
return okResult({
|
|
1440
|
+
...result,
|
|
1441
|
+
questions: filtered,
|
|
1442
|
+
groups: [...groupMap.values()],
|
|
1443
|
+
remaining,
|
|
1444
|
+
});
|
|
1445
|
+
},
|
|
1446
|
+
|
|
1447
|
+
rcf_define_ledger: async (args) => {
|
|
1448
|
+
const argv = buildLedgerArgvFromMcp(args);
|
|
1449
|
+
// Capture the ledger CLI's stdout/stderr; the handler returns JSON
|
|
1450
|
+
// via --json which we parse back for structuredContent.
|
|
1451
|
+
let out = '';
|
|
1452
|
+
let err = '';
|
|
1453
|
+
const stdout = { write: (s) => { out += s; } };
|
|
1454
|
+
const stderr = { write: (s) => { err += s; } };
|
|
1455
|
+
let exitCode;
|
|
1456
|
+
try {
|
|
1457
|
+
exitCode = await ledgerCliMain(argv, { stdout, stderr, cwd: projectRoot });
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
return unexpectedFailureResult({ kind: 'ioFailure', message: /** @type {Error} */ (e).message, stack: /** @type {Error} */ (e).stack }, log);
|
|
1460
|
+
}
|
|
1461
|
+
if (exitCode === 2) {
|
|
1462
|
+
return usageErrorResult(`ledger: ${err.trim() || 'usage error'}`);
|
|
1463
|
+
}
|
|
1464
|
+
if (exitCode !== 0) {
|
|
1465
|
+
return errorResult([{ kind: 'ioFailure', message: `ledger ${args.verb} failed: ${err.trim() || out.trim()}`, documentId: null, field: null, rule: null }]);
|
|
1466
|
+
}
|
|
1467
|
+
// Try to parse JSON; if the verb printed text, pass the text back.
|
|
1468
|
+
let envelope;
|
|
1469
|
+
try {
|
|
1470
|
+
envelope = JSON.parse(out);
|
|
1471
|
+
} catch {
|
|
1472
|
+
envelope = { ok: true, name: args.name, verb: args.verb, text: out };
|
|
1473
|
+
}
|
|
1474
|
+
return okResult(envelope);
|
|
1475
|
+
},
|
|
1122
1476
|
};
|
|
1123
1477
|
|
|
1124
1478
|
async function linkHandler(args, removing) {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Detection model foundation (REQ-172; proposal 2026-09-22 §2.2 v3).
|
|
2
|
+
//
|
|
3
|
+
// Three pure functions:
|
|
4
|
+
//
|
|
5
|
+
// - `hashDocument(doc)` SHA-256 over the canonical JSON of the
|
|
6
|
+
// parsed document. `sha256:<hex>` string.
|
|
7
|
+
// - `computeTreeHash(hashes)` SHA-256 over the sorted `(id, docHash)`
|
|
8
|
+
// pairs, canonically JSON-encoded.
|
|
9
|
+
// - `computeDelta(tree, freeze, ledgers)`
|
|
10
|
+
// the whole change model, returning the
|
|
11
|
+
// shape the proposal contracts in §2.2.
|
|
12
|
+
//
|
|
13
|
+
// Canonical JSON, chosen at proposal §2.1: keys sorted recursively, no
|
|
14
|
+
// whitespace, no trailing separators. A hand reformat that changes
|
|
15
|
+
// nothing is not a change; that is what an operator expects, and it
|
|
16
|
+
// costs one canonicalise per document per read (milliseconds at the
|
|
17
|
+
// backstory-scale tree of 700 criteria). Hashing raw bytes would be
|
|
18
|
+
// one line shorter and would report drift after `rcf define update`
|
|
19
|
+
// rewrote a file it did not semantically touch (ADR-4120).
|
|
20
|
+
//
|
|
21
|
+
// Document granularity (proposal §2.2, ADR-4120): the delta is a set of
|
|
22
|
+
// standalone-document ids -- one entry per document the walker exposes
|
|
23
|
+
// on `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL),
|
|
24
|
+
// plus one `ledger:<name>` entry per sidecar ledger the caller passes.
|
|
25
|
+
// Inline AC and TC entries ride inside the parent hash: editing
|
|
26
|
+
// `AC-207-4` shows as `US-207 changed`, and the criterion-level diff is
|
|
27
|
+
// git's job.
|
|
28
|
+
//
|
|
29
|
+
// Pure: no filesystem I/O, no state, no time. Every call takes plain
|
|
30
|
+
// data in and returns plain data out.
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Canonicalise a value to the JSON string the hash is taken over.
|
|
36
|
+
*
|
|
37
|
+
* - Objects: keys sorted lexicographically, then each `<key>:<value>`
|
|
38
|
+
* joined by `,` and wrapped in `{}`.
|
|
39
|
+
* - Arrays: elements in source order, canonicalised, joined by `,`
|
|
40
|
+
* and wrapped in `[]`.
|
|
41
|
+
* - Strings: `JSON.stringify` (RFC 8259 escaping).
|
|
42
|
+
* - Numbers and booleans: `JSON.stringify` (finite numbers only; NaN
|
|
43
|
+
* and +/-Infinity yield `null` in JSON, which is honest for a value
|
|
44
|
+
* the parsed document could not legally hold).
|
|
45
|
+
* - `null` and `undefined`: `undefined` is omitted from objects and
|
|
46
|
+
* arrays (matches `JSON.stringify` semantics); a top-level
|
|
47
|
+
* `undefined` yields the empty string, which the caller treats as a
|
|
48
|
+
* distinct value from `null` (never expected in a parsed document).
|
|
49
|
+
*
|
|
50
|
+
* No whitespace anywhere. Deterministic on the input tree.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} value
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function canonicaliseJson(value) {
|
|
56
|
+
if (value === null) return 'null';
|
|
57
|
+
const t = typeof value;
|
|
58
|
+
if (t === 'string') return JSON.stringify(value);
|
|
59
|
+
if (t === 'number') return Number.isFinite(value) ? JSON.stringify(value) : 'null';
|
|
60
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
61
|
+
if (t === 'undefined') return '';
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
for (const el of value) {
|
|
65
|
+
// JSON.stringify drops undefined elements to null in arrays; keep
|
|
66
|
+
// that shape so an accidentally-sparse array hashes the same as
|
|
67
|
+
// its JSON.stringify form (never expected in a parsed document,
|
|
68
|
+
// where JSON.parse cannot produce one).
|
|
69
|
+
parts.push(el === undefined ? 'null' : canonicaliseJson(el));
|
|
70
|
+
}
|
|
71
|
+
return `[${parts.join(',')}]`;
|
|
72
|
+
}
|
|
73
|
+
if (t === 'object') {
|
|
74
|
+
const keys = Object.keys(/** @type {object} */ (value)).sort();
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const k of keys) {
|
|
77
|
+
const v = (/** @type {Record<string, unknown>} */ (value))[k];
|
|
78
|
+
if (v === undefined) continue; // JSON.stringify omits undefined in objects.
|
|
79
|
+
parts.push(`${JSON.stringify(k)}:${canonicaliseJson(v)}`);
|
|
80
|
+
}
|
|
81
|
+
return `{${parts.join(',')}}`;
|
|
82
|
+
}
|
|
83
|
+
// Functions, symbols, bigints: never legally present in a parsed
|
|
84
|
+
// document. Fall back to JSON.stringify's own null coercion.
|
|
85
|
+
return 'null';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Hash one parsed document. Returns `sha256:<64-hex>` per proposal
|
|
90
|
+
* §2.1. Accepts any value the walker's `tree.byId` map holds (an
|
|
91
|
+
* object) and also `null` / primitives (a ledger sidecar hashed as a
|
|
92
|
+
* whole, for example, may pass a plain array).
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} doc
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function hashDocument(doc) {
|
|
98
|
+
const canonical = canonicaliseJson(doc);
|
|
99
|
+
const hex = createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
100
|
+
return `sha256:${hex}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hash a `docHashes` map. Sorts the `(id, docHash)` pairs
|
|
105
|
+
* lexicographically by id, canonically encodes the array of pairs,
|
|
106
|
+
* and hashes that string. Matches proposal §2.1 ("the sorted (id,
|
|
107
|
+
* docHash) pairs"). Accepts a plain object or a Map.
|
|
108
|
+
*
|
|
109
|
+
* @param {Record<string, string> | Map<string, string>} docHashes
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function computeTreeHash(docHashes) {
|
|
113
|
+
/** @type {[string, string][]} */
|
|
114
|
+
const pairs = [];
|
|
115
|
+
if (docHashes instanceof Map) {
|
|
116
|
+
for (const [id, h] of docHashes) pairs.push([String(id), String(h)]);
|
|
117
|
+
} else if (docHashes && typeof docHashes === 'object') {
|
|
118
|
+
for (const [id, h] of Object.entries(docHashes)) pairs.push([id, String(h)]);
|
|
119
|
+
}
|
|
120
|
+
pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
121
|
+
const hex = createHash('sha256').update(canonicaliseJson(pairs), 'utf8').digest('hex');
|
|
122
|
+
return `sha256:${hex}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every standalone-document id the walker holds, sorted. Every id in
|
|
127
|
+
* `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL).
|
|
128
|
+
* Inline AC / TC ids ride inside their parent's hash and are NOT
|
|
129
|
+
* enumerated here; delta granularity is the standalone document
|
|
130
|
+
* (proposal §2.2, ADR-4120).
|
|
131
|
+
*
|
|
132
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function standaloneIds(tree) {
|
|
136
|
+
if (!tree || !tree.byId) return [];
|
|
137
|
+
return [...tree.byId.keys()].sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {object} LedgerBundle
|
|
142
|
+
* @property {unknown} [brief] brief-ledger.json body (null / absent = untouched)
|
|
143
|
+
* @property {unknown} [decisions] decisions-ledger.json body
|
|
144
|
+
* @property {unknown} [concerns] concern-ledger.json body
|
|
145
|
+
* @property {unknown} [probes] probe-ledger.json body
|
|
146
|
+
*
|
|
147
|
+
* The delta hashes only the ledgers the caller passes -- if a project
|
|
148
|
+
* has not authored any decisions, the caller passes no `decisions`
|
|
149
|
+
* key and the delta records no `ledger:decisions` docHash. This
|
|
150
|
+
* keeps the model additive: sidecars appear when they exist, never
|
|
151
|
+
* before.
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @typedef {object} FreezeRecord
|
|
156
|
+
* @property {string} [frozenAt]
|
|
157
|
+
* @property {string} [treeHash]
|
|
158
|
+
* @property {Record<string, string>} [docHashes]
|
|
159
|
+
* @property {number} [briefStatements]
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @typedef {object} DeltaResult
|
|
164
|
+
* @property {boolean} frozen
|
|
165
|
+
* false when the freeze record is absent (a fresh tree; everything
|
|
166
|
+
* is `added`, per proposal §2.2 "before the first freeze the delta
|
|
167
|
+
* is the whole tree").
|
|
168
|
+
* @property {string | null} frozenAt
|
|
169
|
+
* @property {string | null} treeHash
|
|
170
|
+
* The tree hash the freeze record stored, or null when unfrozen.
|
|
171
|
+
* @property {string} currentTreeHash
|
|
172
|
+
* The tree hash recomputed from the live tree.
|
|
173
|
+
* @property {string[]} changed ids whose hash differs
|
|
174
|
+
* @property {string[]} added ids not in `docHashes`
|
|
175
|
+
* @property {string[]} removed ids in `docHashes` with no live document
|
|
176
|
+
* @property {number[]} briefSince
|
|
177
|
+
* Ledger-brief statement ids above the freeze high-water mark.
|
|
178
|
+
* @property {number} unchanged count of ids present in both maps with
|
|
179
|
+
* the same hash (excludes ledgers).
|
|
180
|
+
* @property {Record<string, string>} currentDocHashes
|
|
181
|
+
* The live per-document hashes keyed by id (ledgers appear under
|
|
182
|
+
* `ledger:<name>`). Exposed so the viewer can show a document-level
|
|
183
|
+
* hash diff for every changed/added id (spec section 5 block 6).
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Compute the delta between the live tree and the freeze record.
|
|
188
|
+
*
|
|
189
|
+
* Pure. No filesystem, no time, no state. Runs on every read (the
|
|
190
|
+
* proposal's whole point: the freeze record and the tree are the only
|
|
191
|
+
* inputs to the change model).
|
|
192
|
+
*
|
|
193
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
194
|
+
* @param {FreezeRecord | null | undefined} freeze
|
|
195
|
+
* @param {LedgerBundle | null | undefined} [ledgers]
|
|
196
|
+
* @returns {DeltaResult}
|
|
197
|
+
*/
|
|
198
|
+
export function computeDelta(tree, freeze, ledgers) {
|
|
199
|
+
/** @type {Record<string, string>} */
|
|
200
|
+
const currentHashes = {};
|
|
201
|
+
for (const id of standaloneIds(tree)) {
|
|
202
|
+
currentHashes[id] = hashDocument(tree.byId.get(id));
|
|
203
|
+
}
|
|
204
|
+
const ledgerBundle = ledgers ?? {};
|
|
205
|
+
for (const [name, body] of Object.entries(ledgerBundle)) {
|
|
206
|
+
if (body === undefined || body === null) continue;
|
|
207
|
+
currentHashes[`ledger:${name}`] = hashDocument(body);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const currentTreeHash = computeTreeHash(currentHashes);
|
|
211
|
+
|
|
212
|
+
// Unfrozen path (proposal §2.2): the delta is the whole tree,
|
|
213
|
+
// reported as `added`. frozenAt / treeHash null; briefSince empty
|
|
214
|
+
// (there is no high-water mark to compare against yet, and D1's
|
|
215
|
+
// scoping rule keeps the greenfield brief in the delta by definition
|
|
216
|
+
// -- the ledger itself is a docHash entry when the caller passes one).
|
|
217
|
+
if (!freeze || typeof freeze !== 'object' || !freeze.docHashes) {
|
|
218
|
+
return {
|
|
219
|
+
frozen: false,
|
|
220
|
+
frozenAt: null,
|
|
221
|
+
treeHash: null,
|
|
222
|
+
currentTreeHash,
|
|
223
|
+
changed: [],
|
|
224
|
+
added: Object.keys(currentHashes).sort(),
|
|
225
|
+
removed: [],
|
|
226
|
+
briefSince: [],
|
|
227
|
+
unchanged: 0,
|
|
228
|
+
currentDocHashes: { ...currentHashes },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const frozenHashes = freeze.docHashes;
|
|
233
|
+
/** @type {string[]} */
|
|
234
|
+
const changed = [];
|
|
235
|
+
/** @type {string[]} */
|
|
236
|
+
const added = [];
|
|
237
|
+
/** @type {string[]} */
|
|
238
|
+
const removed = [];
|
|
239
|
+
let unchanged = 0;
|
|
240
|
+
|
|
241
|
+
const liveIds = Object.keys(currentHashes);
|
|
242
|
+
for (const id of liveIds) {
|
|
243
|
+
if (Object.prototype.hasOwnProperty.call(frozenHashes, id)) {
|
|
244
|
+
if (frozenHashes[id] === currentHashes[id]) unchanged += 1;
|
|
245
|
+
else changed.push(id);
|
|
246
|
+
} else {
|
|
247
|
+
added.push(id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const id of Object.keys(frozenHashes)) {
|
|
251
|
+
if (!Object.prototype.hasOwnProperty.call(currentHashes, id)) removed.push(id);
|
|
252
|
+
}
|
|
253
|
+
changed.sort();
|
|
254
|
+
added.sort();
|
|
255
|
+
removed.sort();
|
|
256
|
+
|
|
257
|
+
const highWater = Number.isFinite(freeze.briefStatements) ? Number(freeze.briefStatements) : 0;
|
|
258
|
+
/** @type {number[]} */
|
|
259
|
+
const briefSince = [];
|
|
260
|
+
const brief = ledgerBundle.brief;
|
|
261
|
+
if (brief && typeof brief === 'object' && Array.isArray(/** @type {any} */ (brief).statements)) {
|
|
262
|
+
for (const s of /** @type {any} */ (brief).statements) {
|
|
263
|
+
if (!s || typeof s !== 'object') continue;
|
|
264
|
+
const n = Number(s.id);
|
|
265
|
+
if (Number.isFinite(n) && n > highWater) briefSince.push(n);
|
|
266
|
+
}
|
|
267
|
+
briefSince.sort((a, b) => a - b);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
frozen: true,
|
|
272
|
+
frozenAt: typeof freeze.frozenAt === 'string' ? freeze.frozenAt : null,
|
|
273
|
+
treeHash: typeof freeze.treeHash === 'string' ? freeze.treeHash : null,
|
|
274
|
+
currentTreeHash,
|
|
275
|
+
changed,
|
|
276
|
+
added,
|
|
277
|
+
removed,
|
|
278
|
+
briefSince,
|
|
279
|
+
unchanged,
|
|
280
|
+
currentDocHashes: { ...currentHashes },
|
|
281
|
+
};
|
|
282
|
+
}
|