@dzhechkov/harness-core 0.8.6 → 0.8.10

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 (105) hide show
  1. package/.dz-manifest.json +257 -53
  2. package/README.md +48 -1
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.js +1 -1
  15. package/dist/feature-adr-decision-recall.d.ts +167 -0
  16. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  17. package/dist/feature-adr-decision-recall.js +519 -0
  18. package/dist/feature-adr-decision-recall.js.map +1 -0
  19. package/dist/feature-adr-landing.d.ts +37 -0
  20. package/dist/feature-adr-landing.d.ts.map +1 -0
  21. package/dist/feature-adr-landing.js +59 -0
  22. package/dist/feature-adr-landing.js.map +1 -0
  23. package/dist/feature-adr-routing.d.ts +2 -2
  24. package/dist/feature-adr-routing.d.ts.map +1 -1
  25. package/dist/feature-adr-routing.js +7 -11
  26. package/dist/feature-adr-routing.js.map +1 -1
  27. package/dist/guard-promotion.d.ts +41 -0
  28. package/dist/guard-promotion.d.ts.map +1 -1
  29. package/dist/guard-promotion.js +218 -4
  30. package/dist/guard-promotion.js.map +1 -1
  31. package/dist/guard-volume.d.ts +108 -0
  32. package/dist/guard-volume.d.ts.map +1 -0
  33. package/dist/guard-volume.js +536 -0
  34. package/dist/guard-volume.js.map +1 -0
  35. package/dist/guard.d.ts +9 -0
  36. package/dist/guard.d.ts.map +1 -1
  37. package/dist/guard.js +55 -4
  38. package/dist/guard.js.map +1 -1
  39. package/dist/index.d.ts +16 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +11 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/integration-apply.d.ts +25 -0
  44. package/dist/integration-apply.d.ts.map +1 -0
  45. package/dist/integration-apply.js +299 -0
  46. package/dist/integration-apply.js.map +1 -0
  47. package/dist/integration-evidence.d.ts +46 -0
  48. package/dist/integration-evidence.d.ts.map +1 -0
  49. package/dist/integration-evidence.js +44 -0
  50. package/dist/integration-evidence.js.map +1 -0
  51. package/dist/integration-probe-worker.d.ts +22 -0
  52. package/dist/integration-probe-worker.d.ts.map +1 -0
  53. package/dist/integration-probe-worker.js +334 -0
  54. package/dist/integration-probe-worker.js.map +1 -0
  55. package/dist/integrations-verify.d.ts +60 -0
  56. package/dist/integrations-verify.d.ts.map +1 -0
  57. package/dist/integrations-verify.js +194 -0
  58. package/dist/integrations-verify.js.map +1 -0
  59. package/dist/lesson-generalization.d.ts +29 -0
  60. package/dist/lesson-generalization.d.ts.map +1 -0
  61. package/dist/lesson-generalization.js +84 -0
  62. package/dist/lesson-generalization.js.map +1 -0
  63. package/dist/operations.d.ts +27 -0
  64. package/dist/operations.d.ts.map +1 -1
  65. package/dist/operations.js +178 -2
  66. package/dist/operations.js.map +1 -1
  67. package/dist/patterns.d.ts +27 -1
  68. package/dist/patterns.d.ts.map +1 -1
  69. package/dist/patterns.js +211 -45
  70. package/dist/patterns.js.map +1 -1
  71. package/dist/setup.d.ts.map +1 -1
  72. package/dist/setup.js +20 -17
  73. package/dist/setup.js.map +1 -1
  74. package/dist/target-integrations.d.ts +65 -0
  75. package/dist/target-integrations.d.ts.map +1 -0
  76. package/dist/target-integrations.js +152 -0
  77. package/dist/target-integrations.js.map +1 -0
  78. package/dist/vector-tier.d.ts +6 -1
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +32 -7
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +9 -8
  83. package/sbom.json +562 -52
  84. package/src/amendment-trace.ts +12 -1
  85. package/src/compounding.ts +300 -1
  86. package/src/eta.ts +590 -0
  87. package/src/feature-adr-checkpoints.ts +1 -1
  88. package/src/feature-adr-decision-recall.ts +652 -0
  89. package/src/feature-adr-landing.ts +109 -0
  90. package/src/feature-adr-routing.ts +7 -11
  91. package/src/guard-promotion.ts +245 -4
  92. package/src/guard-volume.ts +752 -0
  93. package/src/guard.ts +70 -4
  94. package/src/index.ts +63 -2
  95. package/src/integration-apply.ts +332 -0
  96. package/src/integration-evidence.ts +89 -0
  97. package/src/integration-probe-worker.ts +310 -0
  98. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  99. package/src/integrations-verify.ts +258 -0
  100. package/src/lesson-generalization.ts +115 -0
  101. package/src/operations.ts +199 -2
  102. package/src/patterns.ts +252 -43
  103. package/src/setup.ts +20 -17
  104. package/src/target-integrations.ts +225 -0
  105. package/src/vector-tier.ts +44 -14
@@ -0,0 +1,109 @@
1
+ /** Pure decisions for the Step-7.5 Codex companion liveness barrier. */
2
+
3
+ export const DEFAULT_CODE_LANDING_CEILING_MS = 7_200_000;
4
+ export const CODE_LANDING_CEILING_ENV = 'DZ_FEATURE_ADR_CODE_LANDING_CEILING_MS';
5
+
6
+ export type CodeLandingLivenessVerdict =
7
+ | 'coder-running'
8
+ | 'landed'
9
+ | 'genuinely-not-landed'
10
+ | 'exited-without-edits'
11
+ | 'dead-worker'
12
+ | 'inconclusive';
13
+
14
+ export type CodeLandingLivenessReason =
15
+ | 'recorded-pid-alive'
16
+ | 'recorded-pid-absent'
17
+ | 'terminal-companion'
18
+ | 'ceiling-exceeded'
19
+ | 'companion-probe-error'
20
+ | 'unparseable-companion-status'
21
+ | 'recorded-pid-unavailable'
22
+ | 'git-evidence-unavailable'
23
+ | 'reported-zero-touched-files';
24
+
25
+ export interface CodeLandingLivenessInput {
26
+ readonly companionStatus: unknown;
27
+ readonly recordedPidAlive: boolean | null;
28
+ readonly targetsChanged: boolean | null;
29
+ readonly elapsedMs: number;
30
+ readonly ceilingMs: number;
31
+ /**
32
+ * How many files the companion job itself REPORTED writing (`result.touchedFiles.length`), or
33
+ * null when the record could not be read. Zero from a cleanly-terminated job is a different
34
+ * event from "the window expired": the coder finished and wrote nothing, which is what happens
35
+ * when it asks a question a non-interactive dispatch cannot answer. Folding the two together
36
+ * hides the only cure that works — answer the gate and re-dispatch.
37
+ */
38
+ readonly reportedTouchedFiles?: number | null;
39
+ }
40
+
41
+ export interface CodeLandingLivenessDecision {
42
+ readonly verdict: CodeLandingLivenessVerdict;
43
+ readonly reason: CodeLandingLivenessReason;
44
+ }
45
+
46
+ export function decideCodeLandingLiveness(input: CodeLandingLivenessInput): CodeLandingLivenessDecision {
47
+ const status = typeof input.companionStatus === 'string' ? input.companionStatus.trim().toLowerCase() : ''
48
+ const elapsedMs = Number.isFinite(input.elapsedMs) ? Math.max(0, input.elapsedMs) : 0
49
+ const ceilingMs = Number.isFinite(input.ceilingMs) && input.ceilingMs > 0 ? input.ceilingMs : DEFAULT_CODE_LANDING_CEILING_MS
50
+ const live = status === 'running' || status === 'queued'
51
+ const terminal = status === 'completed' || status === 'failed' || status === 'cancelled'
52
+
53
+ if (live && input.recordedPidAlive === false) {
54
+ return { verdict: 'dead-worker', reason: 'recorded-pid-absent' }
55
+ }
56
+ if (live && input.recordedPidAlive === true) {
57
+ if (elapsedMs >= ceilingMs) return { verdict: 'inconclusive', reason: 'ceiling-exceeded' }
58
+ return { verdict: 'coder-running', reason: 'recorded-pid-alive' }
59
+ }
60
+ if (live) return { verdict: 'inconclusive', reason: 'recorded-pid-unavailable' }
61
+ if (terminal) {
62
+ if (input.targetsChanged === true) return { verdict: 'landed', reason: 'terminal-companion' }
63
+ if (input.targetsChanged === false) {
64
+ if (input.reportedTouchedFiles === 0) {
65
+ return { verdict: 'exited-without-edits', reason: 'reported-zero-touched-files' }
66
+ }
67
+ return { verdict: 'genuinely-not-landed', reason: 'terminal-companion' }
68
+ }
69
+ return { verdict: 'inconclusive', reason: 'git-evidence-unavailable' }
70
+ }
71
+ return {
72
+ verdict: 'inconclusive',
73
+ reason: status === '' ? 'companion-probe-error' : 'unparseable-companion-status',
74
+ }
75
+ }
76
+
77
+ export function extractCodexCompanionJobId(text: unknown): string | null {
78
+ const match = /\bstarted in the background as (task-[a-z0-9]+(?:-[a-z0-9]+)*)\b/i.exec(String(text ?? ''));
79
+ return match && match[1] ? match[1] : null;
80
+ }
81
+
82
+ export interface CodeLandingLivenessProbe {
83
+ readonly companionStatus: string;
84
+ readonly recordedPidAlive: boolean | null;
85
+ readonly targetsChanged: boolean | null;
86
+ readonly elapsedMs: number;
87
+ readonly ceilingMs: number;
88
+ readonly startMs: number;
89
+ readonly reportedTouchedFiles: number | null;
90
+ }
91
+
92
+ export function parseCodeLandingLivenessSignal(text: unknown): CodeLandingLivenessProbe | null {
93
+ const source = String(text ?? '');
94
+ // touched-files is OPTIONAL in the grammar: an older probe, or a state record we could not read,
95
+ // simply omits it and the field stays null — which keeps the verdict at genuinely-not-landed
96
+ // rather than inventing a clean exit. Absence is never evidence here.
97
+ const match = /^CODEX-LIVENESS-SIGNAL companion=([a-z-]+) pid-alive=(true|false|unknown) targets-changed=(true|false|unknown) elapsed-ms=(\d+) ceiling-ms=(\d+) start-ms=(\d+)(?: touched-files=(\d+|unknown))?[ \t]*$/m.exec(source);
98
+ if (!match) return null;
99
+ const asTriState = (value: string): boolean | null => value === 'true' ? true : value === 'false' ? false : null;
100
+ return {
101
+ companionStatus: String(match[1]),
102
+ recordedPidAlive: asTriState(String(match[2])),
103
+ targetsChanged: asTriState(String(match[3])),
104
+ elapsedMs: Number(match[4]),
105
+ ceilingMs: Number(match[5]),
106
+ startMs: Number(match[6]),
107
+ reportedTouchedFiles: match[7] === undefined || match[7] === 'unknown' ? null : Number(match[7]),
108
+ };
109
+ }
@@ -548,15 +548,11 @@ export function codexEffortHint(opts: StageOpts | null | undefined): string {
548
548
 
549
549
  // ── Step-7.5 CODE landed barrier (Codex out-of-band write flush) ────────────
550
550
 
551
- // Widened 2026-08-28 (MEASURED, not a guess): the dz-deadwood coder job ran 46m11s end to end, and
552
- // the slop-lint coder was independently confirmed STILL RUNNING (codex-companion status "running",
553
- // pid alive, mid-TDD-cycle, actively applying file changes) at 16m38s elapsed a full 8x past the
554
- // old 120s window. The old window declared "genuinely not landed" and fed Step-8 QE a false empty
555
- // tree, producing a grade-D report entirely about an absence that was never real. This does not fix
556
- // the underlying conflation of TIMEOUT with CONFIRMED-ABSENCE (filed as a backlog item — the barrier
557
- // still cannot see the codex-companion job's own liveness) — it only narrows how often the false
558
- // negative fires for a realistic M-tier build, at the cost of a longer worst-case wait when the
559
- // coder truly produced nothing.
551
+ // Widened 2026-08-28 (MEASURED): the dz-deadwood coder ran 46m11s, and slop-lint was still running
552
+ // with its recorded PID alive at 16m38s when the old 120s window had already declared absence.
553
+ // Changed 2026-08-30 (MEASURED: six false verdicts; plus task-mtfhglwk stayed status=running for
554
+ // 8h51m after recorded PID 3639268 disappeared): this is now one git-evidence/backoff window inside
555
+ // the liveness-driven loop, not the whole barrier. The separate hard ceiling bounds a live worker.
560
556
  export const DEFAULT_CODE_LANDING_MAX_WAIT_MS = 1_020_000;
561
557
 
562
558
  export const DEFAULT_CODE_LANDING_BACKOFF_MS = [
@@ -604,11 +600,11 @@ export type CodeStageLandingStatus = 'landed' | 'genuinely-not-landed' | 'inconc
604
600
 
605
601
  /** Bumped whenever the landing protocol's meaning changes. Recorded on every code-stage result so a
606
602
  * pre-protocol checkpoint reads as no-checkpoint instead of resuming into new semantics. */
607
- export const LANDING_PROTOCOL_VERSION = 2;
603
+ export const LANDING_PROTOCOL_VERSION = 3;
608
604
 
609
605
  /** R6: salted into the code stage's checkpoint hash PARTS (not the schema version — ADR-003 keeps
610
606
  * `CKPT_SCHEMA_VERSION` at 'fa-ckpt-2' deliberately, so ONLY the code stage re-runs). */
611
- export const LANDING_HASH_TOKEN = 'landing-v2';
607
+ export const LANDING_HASH_TOKEN = 'landing-v3';
612
608
 
613
609
  /** Why a landing verdict is INCONCLUSIVE. Typed, because "we do not know" needs to say WHICH
614
610
  * unknown — the operator repair differs per reason and a single untyped bucket reads as noise. */
@@ -38,6 +38,21 @@ export type RuleTemplate = 'pairing-check' | 'absence-check' | 'format-match';
38
38
 
39
39
  export const TEMPLATES: readonly RuleTemplate[] = ['pairing-check', 'absence-check', 'format-match'];
40
40
 
41
+ export function isOffsetIsoTimestamp(value: unknown): value is string {
42
+ if (typeof value !== 'string') return false;
43
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
44
+ if (match === null || !Number.isFinite(Date.parse(value))) return false;
45
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, offsetHourText, offsetMinuteText] = match;
46
+ const year = Number(yearText);
47
+ const month = Number(monthText);
48
+ const day = Number(dayText);
49
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
50
+ const daysInMonth = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month] ?? 0;
51
+ return day >= 1 && day <= daysInMonth &&
52
+ Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59 &&
53
+ (zone === 'Z' || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59));
54
+ }
55
+
41
56
  export interface TemplateParams {
42
57
  /** pairing-check: the glob whose presence in a change ARMS the rule. */
43
58
  readonly when?: string;
@@ -384,6 +399,23 @@ export function paramsKey(template: RuleTemplate, params: TemplateParams): strin
384
399
  return `${template}|${parts.join('&')}`;
385
400
  }
386
401
 
402
+ /**
403
+ * Exact-byte discriminator of effective promoted-rule content. The ordered tuple keeps boundaries
404
+ * unambiguous; only its two-part digest is persisted, so lesson-derived literals are not copied into
405
+ * observational history. This is correlation identity, not authentication of a locally editable log.
406
+ */
407
+ export function lessonRuleContentAnchor(template: RuleTemplate, params: TemplateParams): string {
408
+ const ordered = Object.keys(params ?? {})
409
+ .sort()
410
+ .map((key) => [key, String((params as Record<string, unknown>)[key])] as const);
411
+ const canonical = JSON.stringify([template, ordered]);
412
+ return `lesson-rule/v1:${fnv1a32(canonical)}${fnv1a32(`\u0000${canonical}`)}`;
413
+ }
414
+
415
+ export function isLessonRuleContentAnchor(value: unknown): value is string {
416
+ return typeof value === 'string' && /^lesson-rule\/v1:[a-f0-9]{16}$/.test(value);
417
+ }
418
+
387
419
  /** A rule already present in the engine or the config, in the shape the dedup check consumes. */
388
420
  export interface ExistingRuleView {
389
421
  readonly id: string;
@@ -531,6 +563,8 @@ export interface LessonInput {
531
563
  export interface PromotionCandidate {
532
564
  readonly lessonId: string;
533
565
  readonly lessonText: string;
566
+ /** False only for quarantined hypotheses; classification alone never makes them eligible. */
567
+ readonly eligible: boolean;
534
568
  readonly ruleId: string | null;
535
569
  readonly template: RuleTemplate | null;
536
570
  readonly params: TemplateParams | null;
@@ -645,7 +679,7 @@ export function assembleCandidates(facts: PromotionFacts): PromotionReport {
645
679
  if (!l || typeof l.dzId !== 'string') continue;
646
680
  const uses = Number.isFinite(l.uses) && l.uses >= 0 ? Math.floor(l.uses) : 0;
647
681
  const cost = 1 + uses;
648
- const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs } as const;
682
+ const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', eligible: l.quarantined !== true, cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs } as const;
649
683
 
650
684
  if (l.quarantined === true) quarantinedSkipped += 1;
651
685
 
@@ -737,6 +771,31 @@ export function assembleCandidates(facts: PromotionFacts): PromotionReport {
737
771
 
738
772
  // ── State (ADR-004) ─────────────────────────────────────────────────────────────────────────────
739
773
 
774
+ /** Enough prospective observations for multi-year monthly reporting without an unbounded journal. */
775
+ export const MAX_PROMOTION_RUN_EVIDENCE = 120;
776
+ /** A larger run is recorded as incomplete, so truncation becomes NOT MEASURED rather than a low count. */
777
+ export const MAX_PROMOTION_RUN_CANDIDATES = 10_000;
778
+ export const MAX_PROMOTION_ACCEPTANCE_EVIDENCE = 10_000;
779
+
780
+ export interface PromotionRunCandidateEvidence {
781
+ readonly candidateAnchor: string;
782
+ readonly eligible: boolean;
783
+ readonly ruleContentAnchor: string | null;
784
+ readonly verdict: CandidateVerdict;
785
+ }
786
+
787
+ export interface PromotionRunEvidence {
788
+ readonly runId: string;
789
+ readonly ts: string;
790
+ readonly complete: boolean;
791
+ readonly candidates: readonly PromotionRunCandidateEvidence[];
792
+ }
793
+
794
+ export interface PromotionAcceptanceEvidence {
795
+ readonly ruleContentAnchor: string;
796
+ readonly acceptedTs: string;
797
+ }
798
+
740
799
  export interface PromotionStateEntry {
741
800
  readonly ruleId: string;
742
801
  readonly lessonId: string;
@@ -759,10 +818,59 @@ export interface PromotionState {
759
818
  readonly version: 1;
760
819
  readonly nextAdrSeq: number;
761
820
  readonly entries: Readonly<Record<string, PromotionStateEntry>>;
821
+ /** Optional so a legacy v1 state remains distinguishable from a recorded zero-candidate run. */
822
+ readonly runs?: readonly PromotionRunEvidence[];
823
+ /** Months whose oldest whole run records were pruned; their promote counts are not measurable. */
824
+ readonly truncatedRunPeriods?: readonly string[];
825
+ /** Compact durable join provenance, independent of bounded per-run retention. */
826
+ readonly acceptances?: readonly PromotionAcceptanceEvidence[];
827
+ /** False means an unmatched promoted firing may belong to pre-feature or pruned provenance. */
828
+ readonly acceptanceHistoryComplete?: boolean;
762
829
  }
763
830
 
764
831
  export const EMPTY_PROMOTION_STATE: PromotionState = { version: 1, nextAdrSeq: 1, entries: {} };
765
832
 
833
+ const CANDIDATE_VERDICTS: readonly CandidateVerdict[] = ['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'];
834
+
835
+ function normalizePromotionRun(raw: unknown): PromotionRunEvidence | null {
836
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
837
+ const value = raw as Record<string, unknown>;
838
+ const runId = value['runId'];
839
+ const ts = value['ts'];
840
+ const candidates = value['candidates'];
841
+ if (typeof runId !== 'string' || runId === '' || runId.length > 512) return null;
842
+ if (!isOffsetIsoTimestamp(ts)) return null;
843
+ if (typeof value['complete'] !== 'boolean' || !Array.isArray(candidates)) return null;
844
+ if (candidates.length > MAX_PROMOTION_RUN_CANDIDATES) return null;
845
+ const normalized: PromotionRunCandidateEvidence[] = [];
846
+ for (const candidate of candidates) {
847
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null;
848
+ const item = candidate as Record<string, unknown>;
849
+ const candidateAnchor = item['candidateAnchor'];
850
+ const ruleContentAnchor = item['ruleContentAnchor'];
851
+ const verdict = item['verdict'];
852
+ if (typeof candidateAnchor !== 'string' || candidateAnchor === '' || candidateAnchor.length > 512) return null;
853
+ if (typeof item['eligible'] !== 'boolean') return null;
854
+ if (ruleContentAnchor !== null && !isLessonRuleContentAnchor(ruleContentAnchor)) return null;
855
+ if (typeof verdict !== 'string' || !CANDIDATE_VERDICTS.includes(verdict as CandidateVerdict)) return null;
856
+ if (verdict === 'promote' && (item['eligible'] !== true || ruleContentAnchor === null)) return null;
857
+ normalized.push({
858
+ candidateAnchor,
859
+ eligible: item['eligible'],
860
+ ruleContentAnchor: ruleContentAnchor as string | null,
861
+ verdict: verdict as CandidateVerdict,
862
+ });
863
+ }
864
+ return { runId, ts, complete: value['complete'], candidates: normalized };
865
+ }
866
+
867
+ function normalizeAcceptance(raw: unknown): PromotionAcceptanceEvidence | null {
868
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
869
+ const value = raw as Record<string, unknown>;
870
+ if (!isLessonRuleContentAnchor(value['ruleContentAnchor']) || !isOffsetIsoTimestamp(value['acceptedTs'])) return null;
871
+ return { ruleContentAnchor: value['ruleContentAnchor'], acceptedTs: value['acceptedTs'] };
872
+ }
873
+
766
874
  /**
767
875
  * Keys that must never become an entry name. `Object.hasOwn` stops a polluted JSON from being READ
768
876
  * through the prototype, but it does not stop `entries[key] = …` from WRITING through it: `JSON.parse`
@@ -784,6 +892,50 @@ export function normalizePromotionState(raw: unknown): PromotionState {
784
892
  const seqRaw = o['nextAdrSeq'];
785
893
  const nextAdrSeq = typeof seqRaw === 'number' && Number.isInteger(seqRaw) && seqRaw >= 1 && seqRaw <= 100_000 ? seqRaw : 1;
786
894
  const entries: Record<string, PromotionStateEntry> = {};
895
+ let runs: PromotionRunEvidence[] | undefined;
896
+ if (Object.hasOwn(o, 'runs')) {
897
+ runs = [];
898
+ if (Array.isArray(o['runs'])) {
899
+ const seen = new Set<string>();
900
+ for (const rawRun of o['runs']) {
901
+ const run = normalizePromotionRun(rawRun);
902
+ if (run === null || seen.has(run.runId)) continue;
903
+ seen.add(run.runId);
904
+ runs.push(run);
905
+ }
906
+ runs.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts) || a.runId.localeCompare(b.runId));
907
+ if (runs.length > MAX_PROMOTION_RUN_EVIDENCE) runs = runs.slice(-MAX_PROMOTION_RUN_EVIDENCE);
908
+ }
909
+ }
910
+ let truncatedRunPeriods: string[] | undefined;
911
+ if (Object.hasOwn(o, 'truncatedRunPeriods')) {
912
+ truncatedRunPeriods = Array.isArray(o['truncatedRunPeriods'])
913
+ ? [...new Set(o['truncatedRunPeriods'].filter((period): period is string =>
914
+ typeof period === 'string' && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(period),
915
+ ))].sort().slice(-12)
916
+ : [];
917
+ }
918
+ let acceptances: PromotionAcceptanceEvidence[] | undefined;
919
+ if (Object.hasOwn(o, 'acceptances')) {
920
+ acceptances = [];
921
+ if (Array.isArray(o['acceptances'])) {
922
+ const byAnchor = new Map<string, PromotionAcceptanceEvidence>();
923
+ for (const rawAcceptance of o['acceptances']) {
924
+ const acceptance = normalizeAcceptance(rawAcceptance);
925
+ if (acceptance === null) continue;
926
+ const prior = byAnchor.get(acceptance.ruleContentAnchor);
927
+ if (prior === undefined || Date.parse(acceptance.acceptedTs) < Date.parse(prior.acceptedTs)) {
928
+ byAnchor.set(acceptance.ruleContentAnchor, acceptance);
929
+ }
930
+ }
931
+ acceptances = [...byAnchor.values()]
932
+ .sort((a, b) => Date.parse(a.acceptedTs) - Date.parse(b.acceptedTs) || a.ruleContentAnchor.localeCompare(b.ruleContentAnchor))
933
+ .slice(-MAX_PROMOTION_ACCEPTANCE_EVIDENCE);
934
+ }
935
+ }
936
+ const acceptanceHistoryComplete = typeof o['acceptanceHistoryComplete'] === 'boolean'
937
+ ? o['acceptanceHistoryComplete']
938
+ : undefined;
787
939
  const rawEntries = o['entries'];
788
940
  if (rawEntries && typeof rawEntries === 'object' && !Array.isArray(rawEntries)) {
789
941
  for (const key of Object.keys(rawEntries as Record<string, unknown>)) {
@@ -810,13 +962,21 @@ export function normalizePromotionState(raw: unknown): PromotionState {
810
962
  lastRunTs: str('lastRunTs') ?? '',
811
963
  wins: int('wins'),
812
964
  evaluatedPeriods: int('evaluatedPeriods'),
813
- verdict: (['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'] as const).includes(str('verdict') as CandidateVerdict) ? (str('verdict') as CandidateVerdict) : 'wait',
965
+ verdict: CANDIDATE_VERDICTS.includes(str('verdict') as CandidateVerdict) ? (str('verdict') as CandidateVerdict) : 'wait',
814
966
  ...(adrSeq !== undefined ? { adrSeq } : {}),
815
967
  ...(str('appliedTs') !== undefined ? { appliedTs: str('appliedTs')! } : {}),
816
968
  };
817
969
  }
818
970
  }
819
- return { version: 1, nextAdrSeq, entries };
971
+ return {
972
+ version: 1,
973
+ nextAdrSeq,
974
+ entries,
975
+ ...(runs !== undefined ? { runs } : {}),
976
+ ...(truncatedRunPeriods !== undefined ? { truncatedRunPeriods } : {}),
977
+ ...(acceptances !== undefined ? { acceptances } : {}),
978
+ ...(acceptanceHistoryComplete !== undefined ? { acceptanceHistoryComplete } : {}),
979
+ };
820
980
  }
821
981
 
822
982
  /**
@@ -850,7 +1010,88 @@ export function nextPromotionState(prev: PromotionState, report: PromotionReport
850
1010
  // Only NEWLY allocated documents advance the sequence — a re-refused candidate rewrites its own
851
1011
  // file, so counting every path would leave permanent gaps in the numbering.
852
1012
  const seq = Number.isInteger(newlyAllocated) && (newlyAllocated as number) >= 0 ? (newlyAllocated as number) : Object.keys(adrSeqs).length;
853
- return { version: 1, nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq), entries };
1013
+ return {
1014
+ version: 1,
1015
+ nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq),
1016
+ entries,
1017
+ ...(base.runs !== undefined ? { runs: base.runs } : {}),
1018
+ ...(base.truncatedRunPeriods !== undefined ? { truncatedRunPeriods: base.truncatedRunPeriods } : {}),
1019
+ ...(base.acceptances !== undefined ? { acceptances: base.acceptances } : {}),
1020
+ ...(base.acceptanceHistoryComplete !== undefined ? { acceptanceHistoryComplete: base.acceptanceHistoryComplete } : {}),
1021
+ };
1022
+ }
1023
+
1024
+ /** Add one prospective observation without changing any promotion decision or candidate report. */
1025
+ export function recordPromotionRunEvidence(
1026
+ state: PromotionState,
1027
+ report: PromotionReport,
1028
+ nowTs: string,
1029
+ ): PromotionState {
1030
+ const base = normalizePromotionState(state);
1031
+ if (!isOffsetIsoTimestamp(nowTs)) return base;
1032
+ const source = Array.isArray(report?.candidates) ? report.candidates : [];
1033
+ const complete = source.length <= MAX_PROMOTION_RUN_CANDIDATES;
1034
+ const candidates: PromotionRunCandidateEvidence[] = source
1035
+ .slice(0, MAX_PROMOTION_RUN_CANDIDATES)
1036
+ .filter((candidate) => typeof candidate?.lessonId === 'string' && candidate.lessonId !== '')
1037
+ .map((candidate) => ({
1038
+ candidateAnchor: candidate.lessonId,
1039
+ eligible: candidate.eligible === true,
1040
+ ruleContentAnchor:
1041
+ candidate.template !== null && candidate.params !== null
1042
+ ? lessonRuleContentAnchor(candidate.template, candidate.params)
1043
+ : null,
1044
+ verdict: CANDIDATE_VERDICTS.includes(candidate.verdict) ? candidate.verdict : 'wait',
1045
+ }));
1046
+ const fingerprint = fnv1a32(JSON.stringify([nowTs, complete, candidates]));
1047
+ const run: PromotionRunEvidence = {
1048
+ runId: `${nowTs}#${fingerprint}`,
1049
+ ts: nowTs,
1050
+ complete,
1051
+ candidates,
1052
+ };
1053
+ const runs = [...(base.runs ?? [])];
1054
+ if (!runs.some((item) => item.runId === run.runId)) runs.push(run);
1055
+ runs.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts) || a.runId.localeCompare(b.runId));
1056
+ const removed = runs.slice(0, Math.max(0, runs.length - MAX_PROMOTION_RUN_EVIDENCE));
1057
+ const retained = runs.slice(-MAX_PROMOTION_RUN_EVIDENCE);
1058
+ const truncatedRunPeriods = [...new Set([
1059
+ ...(base.truncatedRunPeriods ?? []),
1060
+ ...removed.map((item) => new Date(Date.parse(item.ts)).toISOString().slice(0, 7)),
1061
+ ])].sort().slice(-12);
1062
+ const retainedAcceptances = base.acceptances ?? (base.runs ?? []).flatMap((priorRun) =>
1063
+ priorRun.candidates
1064
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleContentAnchor !== null)
1065
+ .map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: priorRun.ts })),
1066
+ );
1067
+ const byAnchor = new Map(retainedAcceptances.map((item) => [item.ruleContentAnchor, item]));
1068
+ for (const candidate of candidates) {
1069
+ if (candidate.verdict !== 'promote' || candidate.ruleContentAnchor === null) continue;
1070
+ const prior = byAnchor.get(candidate.ruleContentAnchor);
1071
+ if (prior === undefined || Date.parse(nowTs) < Date.parse(prior.acceptedTs)) {
1072
+ byAnchor.set(candidate.ruleContentAnchor, { ruleContentAnchor: candidate.ruleContentAnchor, acceptedTs: nowTs });
1073
+ }
1074
+ }
1075
+ const allAcceptances = [...byAnchor.values()]
1076
+ .sort((a, b) => Date.parse(a.acceptedTs) - Date.parse(b.acceptedTs) || a.ruleContentAnchor.localeCompare(b.ruleContentAnchor));
1077
+ const acceptanceOverflow = allAcceptances.length > MAX_PROMOTION_ACCEPTANCE_EVIDENCE;
1078
+ const currentAcceptedRuleIds = new Set(report.candidates
1079
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleId !== null)
1080
+ .map((candidate) => candidate.ruleId!));
1081
+ const hadUnknownAcceptedRules = base.acceptanceHistoryComplete === undefined &&
1082
+ (Object.values(base.entries).some((entry) =>
1083
+ entry.appliedTs !== undefined && !currentAcceptedRuleIds.has(entry.ruleId),
1084
+ ) ||
1085
+ (base.truncatedRunPeriods?.length ?? 0) > 0 ||
1086
+ (base.runs ?? []).some((priorRun) => priorRun.complete !== true));
1087
+ return {
1088
+ ...base,
1089
+ runs: retained,
1090
+ truncatedRunPeriods,
1091
+ acceptances: allAcceptances.slice(-MAX_PROMOTION_ACCEPTANCE_EVIDENCE),
1092
+ acceptanceHistoryComplete:
1093
+ base.acceptanceHistoryComplete !== false && !hadUnknownAcceptedRules && !acceptanceOverflow && complete,
1094
+ };
854
1095
  }
855
1096
 
856
1097
  // ── Rendering ───────────────────────────────────────────────────────────────────────────────────