rcf-lite 0.28.4 → 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.
Files changed (168) hide show
  1. package/CHANGELOG.md +107 -1
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +89 -0
  5. package/guidance/define-intake.md +97 -0
  6. package/guidance/define-intent.md +107 -0
  7. package/guidance/discover-prototype.md +67 -0
  8. package/guidance/discovery-prototypes.md +69 -0
  9. package/guidance/harness-template.md +34 -0
  10. package/guidance/managed/agent-instructions-block.hash +1 -1
  11. package/guidance/managed/agent-instructions-block.md +34 -0
  12. package/guidance/manifest.json +9 -2
  13. package/package.json +1 -1
  14. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  15. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  16. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  17. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  18. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  19. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  20. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  21. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  22. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  23. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  24. package/rcf/code-nodes/cn-725.json +18 -2
  25. package/rcf/code-nodes/cn-726.json +10 -2
  26. package/rcf/code-nodes/cn-727.json +13 -4
  27. package/rcf/code-nodes/cn-728.json +18 -2
  28. package/rcf/code-nodes/cn-729.json +10 -2
  29. package/rcf/code-nodes/cn-730.json +13 -3
  30. package/rcf/code-nodes/cn-731.json +20 -0
  31. package/rcf/code-nodes/cn-732.json +14 -0
  32. package/rcf/code-nodes/cn-733.json +14 -0
  33. package/rcf/code-nodes/cn-734.json +14 -0
  34. package/rcf/code-nodes/cn-735.json +20 -0
  35. package/rcf/code-nodes/cn-736.json +14 -0
  36. package/rcf/code-nodes/cn-737.json +14 -0
  37. package/rcf/code-nodes/cn-738.json +14 -0
  38. package/rcf/code-nodes/cn-739.json +18 -0
  39. package/rcf/code-nodes/cn-740.json +19 -0
  40. package/rcf/code-nodes/cn-741.json +16 -0
  41. package/rcf/code-nodes/cn-742.json +16 -0
  42. package/rcf/code-nodes/cn-743.json +15 -0
  43. package/rcf/code-nodes/cn-744.json +22 -0
  44. package/rcf/code-nodes/cn-745.json +16 -0
  45. package/rcf/code-nodes/cn-746.json +14 -0
  46. package/rcf/code-nodes/cn-747.json +18 -0
  47. package/rcf/code-nodes/cn-748.json +16 -0
  48. package/rcf/code-nodes/cn-749.json +16 -0
  49. package/rcf/code-nodes/cn-750.json +15 -0
  50. package/rcf/code-nodes/cn-751.json +18 -0
  51. package/rcf/code-nodes/cn-752.json +14 -0
  52. package/rcf/fbs/fbs-196.json +13 -5
  53. package/rcf/fbs/fbs-198.json +42 -0
  54. package/rcf/fbs/fbs-199.json +59 -0
  55. package/rcf/fbs/fbs-200.json +46 -0
  56. package/rcf/fbs/fbs-201.json +49 -0
  57. package/rcf/fbs/fbs-202.json +110 -0
  58. package/rcf/fbs/fbs-203.json +100 -0
  59. package/rcf/requirements/req-002.json +2 -2
  60. package/rcf/requirements/req-173.json +2 -2
  61. package/rcf/requirements/req-174.json +10 -4
  62. package/rcf/requirements/req-175.json +10 -4
  63. package/rcf/requirements/req-176.json +25 -0
  64. package/rcf/requirements/req-180.json +31 -0
  65. package/rcf/requirements/req-181.json +31 -0
  66. package/rcf/requirements/req-182.json +25 -0
  67. package/rcf/requirements/req-186.json +25 -0
  68. package/rcf/requirements/req-187.json +31 -0
  69. package/rcf/requirements/req-188.json +25 -0
  70. package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
  71. package/rcf/tacs/tac-4122-define-gates.json +40 -4
  72. package/rcf/tacs/tac-4123-define-readiness.json +39 -3
  73. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  74. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  75. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  76. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  77. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  78. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  79. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  80. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  81. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  82. package/rcf/test-suites/ts-016.json +3 -3
  83. package/rcf/test-suites/ts-215.json +148 -4
  84. package/rcf/test-suites/ts-216.json +19 -3
  85. package/rcf/test-suites/ts-218.json +78 -0
  86. package/rcf/test-suites/ts-219.json +70 -0
  87. package/rcf/test-suites/ts-220.json +78 -0
  88. package/rcf/test-suites/ts-221.json +78 -0
  89. package/rcf/test-suites/ts-222.json +100 -0
  90. package/rcf/test-suites/ts-223.json +94 -0
  91. package/rcf/test-suites/ts-224.json +70 -0
  92. package/rcf/test-suites/ts-225.json +78 -0
  93. package/rcf/test-suites/ts-226.json +158 -0
  94. package/rcf/test-suites/ts-227.json +38 -0
  95. package/rcf/test-suites/ts-228.json +86 -0
  96. package/rcf/test-suites/ts-229.json +83 -0
  97. package/rcf/user-stories/us-17302.json +50 -0
  98. package/rcf/user-stories/us-17401.json +14 -2
  99. package/rcf/user-stories/us-17402.json +55 -0
  100. package/rcf/user-stories/us-17403.json +60 -0
  101. package/rcf/user-stories/us-17502.json +60 -0
  102. package/rcf/user-stories/us-17503.json +65 -0
  103. package/rcf/user-stories/us-17601.json +58 -0
  104. package/rcf/user-stories/us-18001.json +55 -0
  105. package/rcf/user-stories/us-18002.json +35 -0
  106. package/rcf/user-stories/us-18101.json +35 -0
  107. package/rcf/user-stories/us-18202.json +40 -0
  108. package/rcf/user-stories/us-18601.json +55 -0
  109. package/rcf/user-stories/us-18602.json +50 -0
  110. package/rcf/user-stories/us-18603.json +45 -0
  111. package/rcf/user-stories/us-18701.json +50 -0
  112. package/rcf/user-stories/us-18702.json +50 -0
  113. package/rcf/user-stories/us-18801.json +45 -0
  114. package/rcf/user-stories/us-204.json +59 -0
  115. package/rcf/user-stories/us-205.json +64 -0
  116. package/rcf/user-stories/us-206.json +54 -0
  117. package/releases/releases.yaml +11 -1
  118. package/src/cli/freeze.js +730 -0
  119. package/src/cli/help.js +12 -0
  120. package/src/cli/ledger.js +228 -12
  121. package/src/cli/questions.js +288 -0
  122. package/src/cli/readiness.js +267 -49
  123. package/src/cli/view.js +29 -0
  124. package/src/define/freeze-record.js +30 -15
  125. package/src/define/ledgers.js +210 -11
  126. package/src/intake/index.js +1 -1
  127. package/src/intake/orchestrator.js +117 -0
  128. package/src/mcp/tools.js +354 -0
  129. package/src/query/delta.js +6 -0
  130. package/src/query/gates.js +314 -39
  131. package/src/query/index.js +16 -2
  132. package/src/query/questions.js +438 -0
  133. package/src/query/readiness.js +248 -7
  134. package/src/server/index.js +24 -2
  135. package/src/server/routes.js +68 -1
  136. package/src/setup/identity-seed.js +17 -0
  137. package/src/view/build-dag.js +563 -0
  138. package/src/view/components/badge.js +41 -0
  139. package/src/view/components/diff.js +72 -0
  140. package/src/view/components/doc-row.js +62 -0
  141. package/src/view/components/empty-state.js +35 -0
  142. package/src/view/components/entity-selector.js +137 -0
  143. package/src/view/components/filter-bar.js +97 -0
  144. package/src/view/components/findings-list.js +49 -0
  145. package/src/view/components/index.js +12 -0
  146. package/src/view/components/pill.js +46 -0
  147. package/src/view/components/sub-tab-strip.js +43 -0
  148. package/src/view/components/toast.js +25 -0
  149. package/src/view/components-fixture.js +265 -0
  150. package/src/view/doc-renderers/architecture.js +304 -0
  151. package/src/view/doc-renderers/build.js +160 -0
  152. package/src/view/doc-renderers/index.js +2 -0
  153. package/src/view/doc-renderers/prd.js +35 -6
  154. package/src/view/html-page.js +700 -602
  155. package/src/view/index.js +96 -3
  156. package/src/view/live-client.js +39 -6
  157. package/src/view/needs-work.js +79 -0
  158. package/src/view/page-init.js +2193 -0
  159. package/src/view/readiness/phrasebook.js +115 -0
  160. package/src/view/readiness/po-layer.js +288 -0
  161. package/src/view/readiness/question-adapter.js +329 -0
  162. package/src/view/readiness/stage-legend.js +71 -0
  163. package/src/view/readiness.js +505 -0
  164. package/src/view/style.css +2017 -382
  165. package/src/view/test-host/embed-client.js +121 -0
  166. package/src/view/test-host/fixture.js +183 -0
  167. package/src/view/test-host/lints.js +220 -0
  168. package/src/view/view-index.js +323 -0
@@ -189,6 +189,11 @@ function validateEntry(name, entry, filePath, where = 'entry') {
189
189
  filePath, field: `${where}.source`,
190
190
  });
191
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
+ }
192
197
  } else if (name === 'decisions') {
193
198
  if (typeof e.question !== 'string' || e.question.length === 0) {
194
199
  throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
@@ -443,37 +448,231 @@ export function resolveEntry({ name, body, id, patch = {}, now }) {
443
448
  return { body: nextBody, entry: updated };
444
449
  }
445
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
+
446
470
  /**
447
471
  * Parse `--from <file>` content into brief-statement drafts: one entry
448
- * per non-empty line, stripping a leading `- `, `* ` or `<n>. ` bullet.
449
- * The caller supplies the `kind` (or `capability` as the default). All
450
- * statements land as `status: 'open'` at the same `addedAt`; the CLI
451
- * assigns sequential ids.
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.
452
480
  *
453
481
  * @param {string} content
454
482
  * @param {object} [opts]
455
- * @param {string} [opts.kind] default kind for every extracted line
456
- * @param {string} [opts.source] optional source-span label per entry
483
+ * @param {string} [opts.kind] default kind for every unmarked line
484
+ * @param {string} [opts.source] default source for every unmarked line
457
485
  * @returns {Array<{ text: string, kind: string, source?: string }>}
458
486
  */
459
487
  export function parseBriefFromFile(content, opts = {}) {
460
- const kind = opts.kind ?? 'capability';
488
+ const defaultKind = opts.kind ?? 'capability';
461
489
  /** @type {Array<{ text: string, kind: string, source?: string }>} */
462
490
  const drafts = [];
463
491
  for (const raw of content.split(/\r?\n/)) {
464
492
  const trimmed = raw.trim();
465
493
  if (!trimmed) continue;
466
- const stripped = trimmed
494
+ let line = trimmed
467
495
  .replace(/^[-*]\s+/, '')
468
496
  .replace(/^\d+[.)]\s+/, '');
469
- if (!stripped) continue;
470
- const entry = { text: stripped, kind };
471
- if (opts.source) entry.source = opts.source;
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;
472
534
  drafts.push(entry);
473
535
  }
474
536
  return drafts;
475
537
  }
476
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
+
477
676
  /**
478
677
  * Load every ledger the project has actually authored, keyed by name.
479
678
  *
@@ -2,5 +2,5 @@
2
2
 
3
3
  export { classifyFidelity, FIDELITY_LEVELS } from './fidelity.js';
4
4
  export { scanArtefactForFindings } from './validate.js';
5
- export { runIntakePhases } from './orchestrator.js';
5
+ export { runIntakePhases, runIntakeScansOnDelta } from './orchestrator.js';
6
6
  export { writeIntakeRecord, composeIntakeRecord, nextIntakeId } from './manifest-writer.js';
@@ -18,6 +18,123 @@ import { classifyFidelity } from './fidelity.js';
18
18
  import { scanArtefactForFindings } from './validate.js';
19
19
  import { composeIntakeRecord } from './manifest-writer.js';
20
20
 
21
+ /**
22
+ * Run the mechanical intake scans over newly minted brief statements
23
+ * against every pre-existing brief statement. The scans operate on
24
+ * the raw text of the statements; a finding is attributed to the
25
+ * ids of every statement whose text the scan matched (an
26
+ * `impliedButNotStated` cue in one new statement with the matching
27
+ * negated-login phrase elsewhere attributes to both). On a frozen
28
+ * tree the caller passes every pre-existing statement as `existing`;
29
+ * the comparison set is `existing` by definition.
30
+ *
31
+ * Each returned finding carries:
32
+ * { kind, detail, scanName, ids: number[] }
33
+ * where `kind` is one of the three mechanical kinds from
34
+ * `scanArtefactForFindings`, `ids` lists the involved statement ids
35
+ * from `newStatements` first and then `existing` (duplicates
36
+ * removed), and `scanName` is `kind` for the DEFINE PR 1 wiring.
37
+ *
38
+ * @param {Array<{ id: number, text?: string }>} newStatements
39
+ * @param {Array<{ id: number, text?: string }>} existing
40
+ * @returns {Array<{ kind: string, detail: string, scanName: string, ids: number[] }>}
41
+ */
42
+ export function runIntakeScansOnDelta(newStatements, existing) {
43
+ const safeNew = Array.isArray(newStatements) ? newStatements : [];
44
+ const safeExisting = Array.isArray(existing) ? existing : [];
45
+ if (safeNew.length === 0) return [];
46
+
47
+ const combined = [...safeNew, ...safeExisting];
48
+ const combinedText = combined.map((s) => (typeof s?.text === 'string' ? s.text : '')).join('\n');
49
+ const rawFindings = scanArtefactForFindings(combinedText);
50
+ if (rawFindings.length === 0) return [];
51
+
52
+ // A finding only counts when at least one of the newStatements
53
+ // participates. We attribute by re-running the scan over the
54
+ // `newStatements` alone (if that yields the same kind of finding,
55
+ // the new side is sufficient) OR by running the scan over any
56
+ // (new, existing) pair that contains at least one new statement:
57
+ // the second case detects a contradiction between a new and an
58
+ // existing statement.
59
+ /** @type {Array<{ kind: string, detail: string, scanName: string, ids: number[] }>} */
60
+ const out = [];
61
+ const seen = new Set();
62
+ for (const f of rawFindings) {
63
+ const key = `${f.kind}::${f.detail}`;
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ const ids = attributeFinding(f, safeNew, safeExisting);
67
+ if (ids.length === 0) continue;
68
+ out.push({ kind: f.kind, detail: f.detail, scanName: f.kind, ids });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * @param {{ kind: string, detail: string }} finding
75
+ * @param {Array<{ id: number, text?: string }>} newStatements
76
+ * @param {Array<{ id: number, text?: string }>} existing
77
+ * @returns {number[]}
78
+ */
79
+ function attributeFinding(finding, newStatements, existing) {
80
+ /** @type {number[]} */
81
+ const ids = [];
82
+ for (const s of newStatements) {
83
+ if (statementParticipates(finding, s, [...newStatements, ...existing])) {
84
+ ids.push(Number(s.id));
85
+ }
86
+ }
87
+ if (ids.length === 0) return ids;
88
+ // Also attribute an existing statement that participates on the other side.
89
+ for (const s of existing) {
90
+ if (statementParticipates(finding, s, [...newStatements, ...existing])) {
91
+ if (!ids.includes(Number(s.id))) ids.push(Number(s.id));
92
+ }
93
+ }
94
+ return ids;
95
+ }
96
+
97
+ /**
98
+ * Decide whether `statement.text` contributes to `finding`. The scan
99
+ * is pattern-based and finds the same finding kind on the text in
100
+ * isolation if the statement itself carries the full pattern; for
101
+ * cross-statement findings (a contradiction between two statements)
102
+ * the statement contributes when it carries at least one half of the
103
+ * pattern and the other half appears somewhere in the combined set.
104
+ *
105
+ * @param {{ kind: string, detail: string }} finding
106
+ * @param {{ id: number, text?: string }} statement
107
+ * @param {Array<{ id: number, text?: string }>} combined
108
+ */
109
+ function statementParticipates(finding, statement, combined) {
110
+ const text = typeof statement?.text === 'string' ? statement.text : '';
111
+ if (!text) return false;
112
+ const soloFindings = scanArtefactForFindings(text).map((f) => f.kind);
113
+ if (soloFindings.includes(finding.kind)) return true;
114
+ // Cross-statement: pair this statement's text with every other
115
+ // combined text and see whether the pair raises the same finding.
116
+ for (const other of combined) {
117
+ if (other === statement) continue;
118
+ const otherText = typeof other?.text === 'string' ? other.text : '';
119
+ if (!otherText) continue;
120
+ const pairFindings = scanArtefactForFindings(`${text}\n${otherText}`).map((f) => f.kind);
121
+ // Codex review 2026-10-02: a pre-existing finding already
122
+ // raised by `other` alone must not be re-attributed to an
123
+ // unrelated `statement` merely because concatenation leaves
124
+ // the pattern intact. Require the pair to raise the finding
125
+ // that neither side raises alone.
126
+ const otherSolo = scanArtefactForFindings(otherText).map((f) => f.kind);
127
+ if (
128
+ pairFindings.includes(finding.kind)
129
+ && !soloFindings.includes(finding.kind)
130
+ && !otherSolo.includes(finding.kind)
131
+ ) {
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
21
138
  /**
22
139
  * @typedef {'napkin'|'productBrief'|'prd'|'tad'|'other'} ArtefactKind
23
140
  */