@event4u/agent-config 14.13.0 → 14.14.1

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 (138) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +138 -376
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  7. package/dist/agent-src/commands/pr/create.md +16 -1
  8. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  9. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  10. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  11. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  12. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  13. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  14. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/cli/registry.js +1 -0
  18. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  19. package/dist/cli-delegate/cmd_conformance.js +39 -2
  20. package/dist/cli-delegate/cmd_doctor.js +1 -1
  21. package/dist/discovery/deprecation-report.md +1 -1
  22. package/dist/discovery/discovery-manifest.json +7 -7
  23. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  24. package/dist/discovery/discovery-manifest.summary.md +1 -1
  25. package/dist/discovery/dormancy-report.md +1 -1
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +2 -2
  28. package/dist/discovery/trust-report.md +1 -1
  29. package/dist/discovery/workspaces.json +2 -2
  30. package/dist/hooks/dispatch.js +1696 -812
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +297 -115
  34. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  35. package/docs/guidelines/code-clarity.md +18 -0
  36. package/package.json +1 -1
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  38. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  39. package/src/agent-src/scripts/stubs_due.ts +118 -2
  40. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  41. package/src/config/assurance-capability-registry.json +1 -1
  42. package/src/config/estate-count-budget.json +20 -1
  43. package/src/config/evaluator-budgets.json +4 -4
  44. package/src/config/gate-coverage.yml +211 -1
  45. package/src/config/gate-reachability-exemptions.json +1 -0
  46. package/src/config/gate-violation-baselines.json +9 -3
  47. package/src/config/preamble-payload-budget.json +19 -11
  48. package/src/config/rule-stub-ceilings.json +14 -7
  49. package/src/config/web-launch-readiness.json +4 -2
  50. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  51. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  52. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  53. package/src/scripts/_dispatch.bash +11 -4
  54. package/src/scripts/_lib/activation_ladder.ts +47 -3
  55. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  56. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  57. package/src/scripts/_lib/branch_convergence.ts +186 -0
  58. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  59. package/src/scripts/_lib/carrier_status.ts +118 -0
  60. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  61. package/src/scripts/_lib/context_observation.ts +132 -0
  62. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  63. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  64. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  65. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  66. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  67. package/src/scripts/_lib/evolution_roi.ts +562 -0
  68. package/src/scripts/_lib/experiment_binding.ts +95 -0
  69. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  70. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  71. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  72. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  73. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  74. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  75. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  76. package/src/scripts/_lib/pathology_archive.ts +393 -0
  77. package/src/scripts/_lib/promotion_capability.ts +315 -0
  78. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  79. package/src/scripts/_lib/promotion_review.ts +294 -0
  80. package/src/scripts/_lib/prompt_shape.ts +92 -0
  81. package/src/scripts/_lib/release_highlights.ts +79 -1
  82. package/src/scripts/_lib/release_material.ts +125 -2
  83. package/src/scripts/_lib/repeated_failure.ts +19 -2
  84. package/src/scripts/_lib/routing_corpus.ts +307 -0
  85. package/src/scripts/_lib/routing_index_input.ts +104 -0
  86. package/src/scripts/_lib/rule_injection.ts +22 -1
  87. package/src/scripts/_lib/runtime_journal.ts +11 -4
  88. package/src/scripts/_lib/semantic_noop.ts +167 -0
  89. package/src/scripts/activation_receipt.ts +258 -0
  90. package/src/scripts/ai_council/cross_exam.ts +128 -0
  91. package/src/scripts/ai_council/explain_route.ts +157 -0
  92. package/src/scripts/ai_council/information_gain.ts +293 -0
  93. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  94. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  95. package/src/scripts/ai_council/minority_retention.ts +171 -0
  96. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  97. package/src/scripts/ai_council/replay_route.ts +223 -0
  98. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  99. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  100. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  101. package/src/scripts/check_adr_frontmatter.ts +4 -9
  102. package/src/scripts/check_agent_artifact_location.ts +337 -0
  103. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  104. package/src/scripts/check_enforcement_coverage.ts +40 -1
  105. package/src/scripts/check_estate_count.ts +205 -24
  106. package/src/scripts/check_release_highlights.ts +113 -14
  107. package/src/scripts/check_roadmap_trackable.ts +17 -2
  108. package/src/scripts/check_web_launch_readiness.ts +310 -20
  109. package/src/scripts/corpus_manifest.ts +235 -0
  110. package/src/scripts/evolution_lab.ts +234 -9
  111. package/src/scripts/generate_capability_matrix.ts +16 -9
  112. package/src/scripts/grade_target_readiness.ts +221 -2
  113. package/src/scripts/hook_effect_doctor.ts +47 -4
  114. package/src/scripts/hook_manifest.json +1 -1
  115. package/src/scripts/hook_manifest.yaml +28 -6
  116. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  117. package/src/scripts/hooks/concern_registry.ts +2 -0
  118. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  119. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  120. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  121. package/src/scripts/install-hooks.sh +18 -0
  122. package/src/scripts/language_mirror_hook.ts +14 -2
  123. package/src/scripts/lint_carrier_integrity.ts +820 -0
  124. package/src/scripts/lint_code_comments.ts +748 -0
  125. package/src/scripts/lint_plan_risk_register.ts +39 -19
  126. package/src/scripts/lint_promotion_paths.ts +779 -0
  127. package/src/scripts/llm_propose.ts +256 -0
  128. package/src/scripts/measure_delivery_sets.ts +361 -0
  129. package/src/scripts/measure_routing_signal.ts +307 -0
  130. package/src/scripts/model_rule_injection.ts +19 -3
  131. package/src/scripts/probe_council_retention.ts +495 -0
  132. package/src/scripts/release.ts +21 -37
  133. package/src/scripts/release_drill.ts +199 -0
  134. package/src/scripts/release_env.ts +35 -0
  135. package/src/scripts/release_publication.ts +142 -0
  136. package/src/scripts/roadmap_context.ts +15 -0
  137. package/src/scripts/skill_scout.ts +633 -0
  138. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -62,6 +62,12 @@ import {
62
62
  clone_candidate,
63
63
  } from './bench_ab_clone.js';
64
64
  import { main as integrity_main } from './bench_ab_integrity.js';
65
+ import {
66
+ runCascade,
67
+ CHEAPEST_STAGE,
68
+ type CascadeResult,
69
+ } from './_lib/evaluation_cascade.js';
70
+ import type { MetricVector } from './_lib/evaluation_vector.js';
65
71
  import {
66
72
  type CandidateRecord,
67
73
  CandidateSchemaError,
@@ -76,6 +82,15 @@ import {
76
82
  parseCandidateRecord,
77
83
  readCandidateRecord,
78
84
  } from './_lib/candidate_record.js';
85
+ import {
86
+ PROMOTION_EVIDENCE_FIELDS,
87
+ PromotionEvidenceError,
88
+ parsePromotionEvidence,
89
+ } from './_lib/promotion_evidence.js';
90
+ import { MERGE_AUTHORITY_ROADMAP, readMergeAuthorityStatus } from './_lib/promotion_capability.js';
91
+ import { assertNotSemanticNoOp, isSemanticNoOp, SemanticNoOpError } from './_lib/semantic_noop.js';
92
+ import { buildRunSpec } from './_lib/experiment_binding.js';
93
+ import { ExperimentDriftError, assertUnchanged, freeze } from './_lib/experiment_freeze.js';
79
94
  import {
80
95
  RECIPES,
81
96
  byteCompare,
@@ -84,6 +99,13 @@ import {
84
99
  proposeCandidates,
85
100
  serialiseCandidateRecord,
86
101
  } from './_lib/candidate_proposer.js';
102
+ import {
103
+ type RunReport,
104
+ buildRunReport,
105
+ parseMetricVectorJson,
106
+ renderRunReport,
107
+ roiFigure,
108
+ } from './_lib/evolution_roi.js';
87
109
  import {
88
110
  BudgetExceededError,
89
111
  type DisclosureRecord,
@@ -379,11 +401,11 @@ const USAGE = `usage: evolution_lab <verb> [options]
379
401
 
380
402
  inspect [--record FILE]... [--records DIR] [--clones]
381
403
  propose --observations FILE --out DIR [--force]
382
- run --record FILE... [--refresh]
404
+ run --record FILE... [--refresh] [--vector FILE]...
383
405
  [--trials-per-candidate N] [--estimated-spend-cents N]
384
406
  compare [--verbose]
385
407
  explain [--record FILE [--to STATE]] [--criteria]
386
- promote --record FILE (always refuses: blocker merge-authority)
408
+ promote --record FILE [--evidence FILE] (always refuses: blocker merge-authority)
387
409
  clean [--yes]
388
410
 
389
411
  No verb starts a resident process, a timer, or a watcher.
@@ -615,6 +637,15 @@ function guardAbort(e: unknown): number | undefined {
615
637
  );
616
638
  return EXIT_GUARD_ABORT;
617
639
  }
640
+ if (e instanceof ExperimentDriftError) {
641
+ process.stderr.write(
642
+ `evolution_lab: ABORTED on experiment drift (moved: ${e.changed.join(', ')})\n` +
643
+ ` ${e.message}\n` +
644
+ ' The run cloned one record set and would have scored another. Nothing is\n' +
645
+ ' reported: an ROI figure over a set that moved mid-run is not a comparison.\n',
646
+ );
647
+ return EXIT_GUARD_ABORT;
648
+ }
618
649
  if (e instanceof BudgetConfigError) {
619
650
  process.stderr.write(`evolution_lab: ABORTED — ${(e as Error).message}\n`);
620
651
  return EXIT_GUARD_ABORT;
@@ -698,7 +729,7 @@ function verbRun(argv: readonly string[]): number {
698
729
  const flags = parseFlags(
699
730
  argv,
700
731
  ['refresh'],
701
- ['record', 'records', 'trials-per-candidate', 'estimated-spend-cents'],
732
+ ['record', 'records', 'trials-per-candidate', 'estimated-spend-cents', 'vector'],
702
733
  );
703
734
  const files: string[] = [...(flags.values.get('record') ?? [])];
704
735
  const dir = one(flags, 'records');
@@ -734,8 +765,51 @@ function verbRun(argv: readonly string[]): number {
734
765
  }
735
766
  throw e;
736
767
  }
768
+ // Evaluation evidence is parsed BEFORE the first clone, for the same reason
769
+ // the budget guard runs before it: a malformed vector discovered after the
770
+ // work is a failed run that already spent, and `parseMetricVectorJson`
771
+ // inherits `buildVector`'s refusal of a vector missing its artifact-count
772
+ // row, so this is also where that refusal lands.
773
+ const vectors: MetricVector[] = [];
774
+ for (const vf of flags.values.get('vector') ?? []) {
775
+ try {
776
+ vectors.push(parseMetricVectorJson(fs.readFileSync(vf, 'utf-8'), vf));
777
+ } catch (e) {
778
+ return fail(`vector ${vf} rejected — ${(e as Error).message}`);
779
+ }
780
+ }
781
+ // The experiment binding. Frozen HERE, before the first clone, and asserted
782
+ // again after the loop. The window it closes is real and specific: the loop
783
+ // below clones from `record`, then RE-READS the same file from disk to
784
+ // evaluate it, so a record rewritten in between produces a run that cloned
785
+ // one thing and scored another. Every guard already in this verb fires
786
+ // before the run starts and never looks again.
787
+ const ordered = [...files].sort(byteCompare);
788
+ const readRecord = (f: string): string => {
789
+ try {
790
+ return fs.readFileSync(f, 'utf-8');
791
+ } catch {
792
+ // Unreadable is a STATE of the record set here, not a reason to
793
+ // fail: the loop below already reports the specific file with its
794
+ // own message, and a freeze that pre-empted it would replace a
795
+ // precise diagnostic with a vaguer one. The marker still moves the
796
+ // digest, so a record that appears or vanishes mid-run is drift.
797
+ return '\u0000<unreadable>';
798
+ }
799
+ };
800
+ let frozenSpec;
801
+ let frozenDigest: string;
802
+ try {
803
+ frozenSpec = buildRunSpec(ordered, readRecord);
804
+ frozenDigest = freeze(frozenSpec);
805
+ } catch (e) {
806
+ return fail(`experiment spec could not be built — ${(e as Error).message}`);
807
+ }
808
+
737
809
  const seen = new Set<string>();
738
- for (const f of files.sort(byteCompare)) {
810
+ const ids: string[] = [];
811
+ const results: CascadeResult[] = [];
812
+ for (const f of ordered) {
739
813
  let record: CandidateRecord;
740
814
  try {
741
815
  record = loadRecord(f);
@@ -754,6 +828,108 @@ function verbRun(argv: readonly string[]): number {
754
828
  }
755
829
  return fail(`candidate ${record.id} failed — ${(e as Error).message}`);
756
830
  }
831
+
832
+ // EVALUATE. Step 4.1's deterministic prefix, wired here because a
833
+ // library nothing calls has no coverage — the defect AC-3 and AC-5
834
+ // were both open on. The record is re-read from disk rather than
835
+ // reusing `record`, so stage 1 is a real schema gate at this call
836
+ // site and not a formality over an already-parsed object.
837
+ let raw: unknown;
838
+ try {
839
+ raw = JSON.parse(fs.readFileSync(f, 'utf-8'));
840
+ } catch (e) {
841
+ return fail(`${f} unreadable at evaluation — ${(e as Error).message}`);
842
+ }
843
+ const result = runCascade({
844
+ raw,
845
+ plan: {
846
+ candidates: files.length,
847
+ trialsPerCandidate: intFlag(flags, 'trials-per-candidate', 1),
848
+ estimatedSpendCents: intFlag(flags, 'estimated-spend-cents', 0),
849
+ },
850
+ budget: loadRunBudget(),
851
+ peers: ids,
852
+ // ONE evidence input, two consumers. `--vector` is the only
853
+ // measurement flag: step 5.6's run report and step 4.1's cascade
854
+ // read the same parsed vectors rather than each taking a file of
855
+ // its own, so a run cannot report an ROI over one set of numbers
856
+ // while the verdict was decided on another.
857
+ vector: vectors.find((v) => v.candidate_id === record.id),
858
+ });
859
+ ids.push(record.id);
860
+ results.push(result);
861
+ }
862
+
863
+ // Assert BEFORE the verdicts are rendered, and before the cascade-abort
864
+ // return below: a drifted run has no verdict worth printing, whichever way
865
+ // the cascade came out.
866
+ try {
867
+ assertUnchanged(frozenDigest, frozenSpec, buildRunSpec(ordered, readRecord));
868
+ } catch (e) {
869
+ const aborted = guardAbort(e);
870
+ if (aborted !== undefined) {
871
+ return aborted;
872
+ }
873
+ throw e;
874
+ }
875
+
876
+ for (const r of results) {
877
+ if (r.outcome === 'abort') {
878
+ process.stdout.write(
879
+ `evolution_lab:cascade · ${r.candidate_id ?? '<unparsed>'} · aborted at ` +
880
+ `${r.failed_stage} · family=${r.family} · model_calls=${r.model_calls} · ${r.detail}\n`,
881
+ );
882
+ } else if (r.outcome === 'incomplete') {
883
+ process.stdout.write(
884
+ `evolution_lab:cascade · ${r.candidate_id} · passed ${r.stages_run.length} stage(s) · ` +
885
+ `model_calls=${r.model_calls} · ${r.not_reached} NOT REACHED · ${r.why}\n`,
886
+ );
887
+ } else {
888
+ process.stdout.write(
889
+ `evolution_lab:cascade · ${r.candidate_id} · passed ${r.stages_run.length} stage(s) · ` +
890
+ `model_calls=${r.model_calls} · verdict=${r.verdict.promote ? 'promote' : 'refuse'} · ` +
891
+ `${r.verdict.reason}\n`,
892
+ );
893
+ }
894
+ }
895
+
896
+ // A cascade abort is a run outcome, not a crash: the run did its job by
897
+ // refusing. The cheapest stage is named so a reader can see that a stage-1
898
+ // abort cost nothing.
899
+ const aborted = results.filter((r) => r.outcome === 'abort');
900
+ if (aborted.length > 0) {
901
+ const atCheapest = aborted.filter(
902
+ (r) => r.outcome === 'abort' && r.failed_stage === CHEAPEST_STAGE,
903
+ ).length;
904
+ process.stdout.write(
905
+ `evolution_lab:cascade · ${aborted.length} of ${results.length} aborted ` +
906
+ `(${atCheapest} at the cheapest stage, ${CHEAPEST_STAGE}, costing no model call)\n`,
907
+ );
908
+ return EXIT_REFUSED;
909
+ }
910
+ // STEP 5.6 — the run report, on the ONE path a run completes on.
911
+ //
912
+ // Placed after the clone loop and before the only success return, so there
913
+ // is no completed run without a report. Every other exit from this verb is
914
+ // an abort (budget) or a failure (unreadable record), and neither is a run
915
+ // whose ROI could be reported: nothing was cloned and nothing was spent.
916
+ let report: RunReport;
917
+ try {
918
+ report = buildRunReport({
919
+ // Deterministic and identifying: the candidate ids, in the byte
920
+ // order the run walked them. Two runs over the same record set
921
+ // produce the same id, and a run over a different set cannot
922
+ // borrow another run's report line.
923
+ run_id: `run:${[...seen].sort(byteCompare).join('+')}`,
924
+ candidates: files.length,
925
+ trials_per_candidate: intFlag(flags, 'trials-per-candidate', 1),
926
+ roi: roiFigure(vectors, intFlag(flags, 'estimated-spend-cents', 0)),
927
+ });
928
+ } catch (e) {
929
+ return fail(`run report rejected — ${(e as Error).message}`);
930
+ }
931
+ for (const line of renderRunReport(report)) {
932
+ process.stdout.write(`${line}\n`);
757
933
  }
758
934
  return EXIT_OK;
759
935
  }
@@ -854,9 +1030,15 @@ function verbExplain(argv: readonly string[]): number {
854
1030
  * There is no flag, no environment variable and no argument that turns this
855
1031
  * into a promotion. `--approver` is deliberately NOT a flag: adding one is what
856
1032
  * would let an unattended run name a human who never approved.
1033
+ *
1034
+ * `--evidence FILE` IS a flag, and it changes nothing about the refusal. It is
1035
+ * how steps 7.1, 7.3, 7.4 and 7.5 are exercised against a refused promotion: the
1036
+ * package is validated FIRST and its finding names the missing field, so an
1037
+ * operator assembling one can see which field is wrong instead of only being
1038
+ * told the blocker is open. Both paths still return {@link EXIT_REFUSED}.
857
1039
  */
858
1040
  function verbPromote(argv: readonly string[]): number {
859
- const flags = parseFlags(argv, [], ['record']);
1041
+ const flags = parseFlags(argv, [], ['record', 'evidence']);
860
1042
  const file = one(flags, 'record');
861
1043
  if (file === undefined) {
862
1044
  return usageError('promote requires --record FILE');
@@ -867,20 +1049,63 @@ function verbPromote(argv: readonly string[]): number {
867
1049
  } catch (e) {
868
1050
  return fail(`${file} rejected — ${(e as Error).message}`);
869
1051
  }
1052
+
1053
+ // 7.1 / 7.3 / 7.4 / 7.5 — the evidence package, checked BEFORE the governance
1054
+ // refusal so its finding names the missing field rather than being swallowed
1055
+ // by a blocker message the operator cannot act on.
1056
+ const evidenceFile = one(flags, 'evidence');
1057
+ if (evidenceFile === undefined) {
1058
+ process.stderr.write(
1059
+ `evolution_lab: promote REFUSED for ${record.id}\n` +
1060
+ ' evidence package: absent. A promotion carries ONE package with all ' +
1061
+ `${String(PROMOTION_EVIDENCE_FIELDS.length)} fields ` +
1062
+ `(${PROMOTION_EVIDENCE_FIELDS.join(', ')}); pass --evidence FILE.\n`,
1063
+ );
1064
+ } else {
1065
+ try {
1066
+ const evidence = parsePromotionEvidence(JSON.parse(fs.readFileSync(evidenceFile, 'utf-8')));
1067
+ assertNotSemanticNoOp(
1068
+ evidence.candidateId,
1069
+ isSemanticNoOp(
1070
+ evidence.materialImprovement.baselineText,
1071
+ evidence.materialImprovement.candidateText,
1072
+ evidence.materialImprovement.deltaPercent,
1073
+ ),
1074
+ );
1075
+ } catch (e) {
1076
+ if (e instanceof PromotionEvidenceError || e instanceof SemanticNoOpError) {
1077
+ process.stderr.write(
1078
+ `evolution_lab: promote REFUSED for ${record.id}\n evidence package: ${e.message}\n`,
1079
+ );
1080
+ } else {
1081
+ return fail(`${evidenceFile} unreadable — ${(e as Error).message}`);
1082
+ }
1083
+ }
1084
+ }
1085
+
870
1086
  let gate = 'the lifecycle gate did not refuse, which is itself a defect';
871
1087
  try {
872
1088
  assertTransition(record.lifecycle, 'promoted');
873
1089
  } catch (e) {
874
1090
  gate = (e as Error).message;
875
1091
  }
1092
+ // READ the status, never assert it. This line said `is OPEN` as a literal,
1093
+ // and on 2026-09-01 the blocker closed as `refused` — at which point the
1094
+ // refusal was still correct and its stated reason was not. Reading keeps the
1095
+ // two from diverging again, and every value the reader can return is a
1096
+ // refusing one, so no branch here can print a grant.
1097
+ const blockerStatus = readMergeAuthorityStatus();
876
1098
  process.stderr.write(
877
1099
  `evolution_lab: promote REFUSED for ${record.id}\n` +
878
1100
  ` lifecycle gate: ${gate}\n` +
879
- ' blocker: merge-authority is OPEN on road-to-governed-harness-evolution.\n' +
1101
+ ` blocker: merge-authority reads ${blockerStatus.toUpperCase()} on ` +
1102
+ `${path.basename(MERGE_AUTHORITY_ROADMAP, '.md')}.\n` +
880
1103
  ' Phases 1-6 are legal because they promote nothing (AI council 2026-08-29,\n' +
881
- ' anthropic + openai, 2/2). Phase 7 stays gated: granting merge authority\n' +
882
- ' weakens a human-in-the-loop guarantee and refusing it settles ADR-239\n' +
883
- ' Decision 3, and both are owner-reserved.\n' +
1104
+ ' anthropic + openai, 2/2). Phase 7 was gated on this blocker; on 2026-09-01\n' +
1105
+ ' the AI council settled ADR-239 Decision 3 in the REFUSING direction, scoped\n' +
1106
+ ' to PREAUTHORIZED authority, and the blocker closed as `refused`. Only\n' +
1107
+ ' `Status: resolved` AND `Disposition: granted` mint the capability, so a\n' +
1108
+ ' refusal keeps this verb shut exactly as an open blocker did.\n' +
884
1109
  ' Promotion into canonical agent-config remains a named human act performed\n' +
885
1110
  ' outside this tool. This verb will not perform it.\n',
886
1111
  );
@@ -72,7 +72,7 @@ interface FnSpec {
72
72
  cells: Record<string, string>;
73
73
  }
74
74
 
75
- // Each condense.py dispatcher generator → what it emits, for which host(s),
75
+ // Each condense.ts dispatcher generator → what it emits, for which host(s),
76
76
  // via which mechanism. Universal generators (symlink fan-out to every host
77
77
  // that consumes the artifact) list all consuming hosts explicitly. This is the
78
78
  // one place the host×artifact mechanism is asserted; the coverage guard below
@@ -222,10 +222,17 @@ export function render_md(matrix: Record<string, Record<string, string>>): strin
222
222
  const lines: string[] = [
223
223
  '# Capability matrix — what works on which host',
224
224
  '',
225
- '> **Generated** by `generate_capability_matrix` — do NOT',
226
- '> hand-edit. Derived from the `generate_tools()` projection logic in',
227
- '> `condense.py` (each cell traces to a `generate_*` dispatcher call).',
228
- '> Drift-checked in CI (`--check`).',
225
+ '> **Generated — still generated, not hand-maintained prose wearing a',
226
+ '> generated header.** Emitted by `src/scripts/generate_capability_matrix.ts`;',
227
+ '> do NOT hand-edit. Derived from the `generate_tools()` projection logic in',
228
+ '> `src/scripts/condense.ts` (each cell traces to a `generate_*` dispatcher',
229
+ '> call). Drift-checked in CI (`--check`).',
230
+ '>',
231
+ '> Until 2026-09-03 this header named two Python-era generator paths that',
232
+ '> have not existed since ADR-200 ended that era, so a reader checking the',
233
+ '> provenance of this table found dead paths and no way to tell whether',
234
+ '> anything still produced it. They are described rather than reproduced',
235
+ '> here, so every path this header names resolves.',
229
236
  '',
230
237
  'Cells: **✅ native** (host consumes the artifact directly — symlink /',
231
238
  'native dir) · **🔁 adapter** (projected through a host-specific',
@@ -337,8 +344,8 @@ interface ParsedArgs {
337
344
  class _ArgExit extends Error {}
338
345
 
339
346
  function _argError(msg: string): never {
340
- process.stderr.write('usage: generate_capability_matrix.py [-h] [--check] [--quiet]\n');
341
- process.stderr.write(`generate_capability_matrix.py: error: ${msg}\n`);
347
+ process.stderr.write('usage: generate_capability_matrix [-h] [--check] [--quiet]\n');
348
+ process.stderr.write(`generate_capability_matrix: error: ${msg}\n`);
342
349
  process.exitCode = 2;
343
350
  throw new _ArgExit();
344
351
  }
@@ -347,7 +354,7 @@ function parse_args(argv: string[]): ParsedArgs {
347
354
  const out: ParsedArgs = { check: false, quiet: false };
348
355
  for (const a of argv) {
349
356
  if (a === '-h' || a === '--help') {
350
- process.stdout.write('usage: generate_capability_matrix.py [-h] [--check] [--quiet]\n');
357
+ process.stdout.write('usage: generate_capability_matrix [-h] [--check] [--quiet]\n');
351
358
  process.exitCode = 0;
352
359
  throw new _ArgExit();
353
360
  } else if (a === '--check') {
@@ -383,7 +390,7 @@ export function main(argv: string[] | null = null): number {
383
390
  const missing = coverage_guard();
384
391
  if (missing.length > 0) {
385
392
  process.stderr.write(
386
- '❌ generate_capability_matrix: condense.py dispatcher has ' +
393
+ '❌ generate_capability_matrix: condense.ts dispatcher has ' +
387
394
  `generator(s) not mapped in _FN_SPEC: ${_pyListRepr(missing)}. Add an _FN_SPEC ` +
388
395
  'entry so the matrix stays derived (never silently drift).\n',
389
396
  );
@@ -7,7 +7,7 @@
7
7
  * written in a command's markdown cannot be asserted by anything. So the
8
8
  * detection is here and the command invokes it; the command keeps the narrative.
9
9
  *
10
- * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Ten dimensions are
10
+ * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Thirteen dimensions are
11
11
  * graded 0 Absent / 1 Present / 2 Enforced-in-CI / 3 Independent-and-diff-scoped,
12
12
  * and the verdict is `min` over the FOUR knockout dimensions, reported as
13
13
  * `L<n> — bound by <dimension>`. **No aggregate number is ever emitted** — no
@@ -113,6 +113,66 @@ function _ciText(root: string): string {
113
113
  return out;
114
114
  }
115
115
 
116
+ /* -- bounded text corpus, for the Phase 4 dimensions ----------------------- */
117
+
118
+ /** Directories a target scan must never descend into: cost with no signal. */
119
+ const _SKIP_DIRS = new Set([
120
+ 'node_modules', '.git', 'vendor', 'dist', 'build', 'out', 'coverage',
121
+ '.next', '.nuxt', '.venv', 'venv', '__pycache__', 'target', '.terraform',
122
+ ]);
123
+ /** Extensions that can carry a DNS record, a policy doc or an ops runbook. */
124
+ const _CORPUS_RE = /\.(tf|tfvars|hcl|ya?ml|json|txt|zone|dns|ini|conf|cfg|toml|md|env|example|sh)$/i;
125
+ const _CORPUS_MAX_FILES = 2000;
126
+ const _CORPUS_MAX_BYTES = 256 * 1024;
127
+
128
+ /**
129
+ * Read a bounded slice of the target's text, once, for the dimensions that
130
+ * cannot be answered by a root-level `_exists` probe.
131
+ *
132
+ * Three dimensions need to see INSIDE files — a DMARC policy is a string in a
133
+ * zone file or a Terraform record, not a filename — and the existing probes are
134
+ * all filename-shaped. The walk is capped in three directions (skip list, file
135
+ * count, per-file bytes) because an unbounded read of an arbitrary target repo
136
+ * is a cost this grader has no way to predict.
137
+ *
138
+ * Returns the concatenated text AND the relative paths, because a dimension that
139
+ * says WHERE it saw something is auditable and one that only says "somewhere in
140
+ * the tree" is not.
141
+ */
142
+ export function _textCorpus(root: string): { text: string; paths: string[] } {
143
+ const paths: string[] = [];
144
+ let text = '';
145
+ const walk = (dir: string, rel: string): void => {
146
+ if (paths.length >= _CORPUS_MAX_FILES) return;
147
+ let entries: fs.Dirent[];
148
+ try {
149
+ entries = fs.readdirSync(dir, { withFileTypes: true });
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
154
+ if (paths.length >= _CORPUS_MAX_FILES) return;
155
+ const abs = path.join(dir, e.name);
156
+ const r = rel === '' ? e.name : `${rel}/${e.name}`;
157
+ if (e.isDirectory()) {
158
+ if (_SKIP_DIRS.has(e.name)) continue;
159
+ walk(abs, r);
160
+ continue;
161
+ }
162
+ if (!_CORPUS_RE.test(e.name)) continue;
163
+ try {
164
+ if (fs.statSync(abs).size > _CORPUS_MAX_BYTES) continue;
165
+ text += `${fs.readFileSync(abs, 'utf-8')}\n`;
166
+ } catch {
167
+ continue;
168
+ }
169
+ paths.push(r);
170
+ }
171
+ };
172
+ walk(root, '');
173
+ return { text, paths };
174
+ }
175
+
116
176
  /**
117
177
  * Is this a Python-primary target?
118
178
  *
@@ -125,7 +185,7 @@ export function isPythonTarget(root: string): boolean {
125
185
  return py && !other;
126
186
  }
127
187
 
128
- // ── the ten dimensions ─────────────────────────────────────────────────────
188
+ // ── the thirteen dimensions ──
129
189
 
130
190
  /**
131
191
  * Grade one dimension. `ci` decides 1 → 2: a thing that exists is `Present`; a
@@ -149,6 +209,113 @@ function _ciBlocks(ci: string, ...needles: string[]): boolean {
149
209
  return !allSoft;
150
210
  }
151
211
 
212
+ /* -- Phase 4 dimension ladders --------------------------------------------- */
213
+
214
+ interface Scored {
215
+ grade: Grade;
216
+ evidence: string;
217
+ }
218
+
219
+ /** A runbook, a restore procedure, or a rollback path — and whether CI runs it. */
220
+ function _recovery(corpus: { text: string; paths: string[] }, ci: string): Scored {
221
+ const namedFile = corpus.paths.some((p) => /(^|\/)(runbook|runbooks|disaster-recovery|dr)([./-]|$)/i.test(p));
222
+ const procedure = /\b(restore from backup|disaster recovery|rollback plan|pg_restore|pg_dump|restic|velero|RPO|RTO)\b/i.test(corpus.text);
223
+ const present = namedFile || procedure;
224
+ const enforced = _ciBlocks(ci, 'restore', 'rollback', 'disaster-recovery', 'backup');
225
+ // A DRILL is what separates a documented procedure from a proven one. It is
226
+ // the only honest route to 3 here, because "independent" for recovery means
227
+ // the restore was actually exercised rather than described.
228
+ const drill = /\b(restore (drill|test|rehearsal)|recovery drill|game ?day)\b/i.test(corpus.text);
229
+ const seen: string[] = [
230
+ ...(namedFile ? ['a runbook or DR document'] : []),
231
+ ...(procedure ? ['a restore/rollback procedure in the text'] : []),
232
+ ...(enforced ? ['a blocking CI step naming restore/rollback/backup'] : []),
233
+ ...(drill ? ['a restore drill'] : []),
234
+ ];
235
+ return {
236
+ grade: _graded(present, enforced, drill),
237
+ evidence: seen.length > 0 ? seen.join('; ') : 'no runbook, restore procedure or rollback path found',
238
+ };
239
+ }
240
+
241
+ /**
242
+ * SPF, DKIM and DMARC — and the rung that must not be fudged (4.2).
243
+ *
244
+ * 0 Absent — no SPF, DKIM or DMARC RECORD anywhere. Prose about DMARC is
245
+ * not a DMARC record; the signal is the record shape.
246
+ * 1 Present — SPF and/or DKIM published, OR a DMARC record with `p=none`.
247
+ * `p=none` is a MONITORING policy: it requests aggregate reports
248
+ * and instructs receivers to take no action on a failing message.
249
+ * A domain publishing it is measuring the problem, not preventing
250
+ * it, so it is capped here however much else sits alongside it.
251
+ * 2 Enforced — DMARC `p=quarantine` or `p=reject`, with SPF or DKIM to align
252
+ * against. An enforcing policy with nothing to align is a policy
253
+ * over no signal, and stays at 1.
254
+ * 3 Top — `p=reject` with BOTH SPF and DKIM and an `rua=` reporting
255
+ * address, so the enforcement can be observed rather than assumed.
256
+ */
257
+ function _mailAuthenticity(corpus: { text: string }): Scored {
258
+ const t = corpus.text;
259
+ const spf = /v=spf1\b/i.test(t);
260
+ const dkim = /v=DKIM1\b/i.test(t);
261
+ const dmarc = /v=DMARC1\b/i.test(t);
262
+ const policy = /v=DMARC1[^"'\n]*?\bp\s*=\s*(none|quarantine|reject)\b/i.exec(t)?.[1]?.toLowerCase();
263
+ const rua = /v=DMARC1[^"'\n]*?\brua\s*=/i.test(t);
264
+ const aligned = spf || dkim;
265
+ const parts = [
266
+ spf ? 'SPF published' : 'no SPF record',
267
+ dkim ? 'DKIM published' : 'no DKIM record',
268
+ dmarc ? `DMARC published (p=${policy ?? 'unset'})` : 'no DMARC record',
269
+ ];
270
+ if (!spf && !dkim && !dmarc) {
271
+ return { grade: 0, evidence: 'no SPF, DKIM or DMARC record found in the tree' };
272
+ }
273
+ if (policy === 'none') {
274
+ return {
275
+ grade: 1,
276
+ evidence:
277
+ `${parts.join('; ')} — capped at Present: p=none is a monitoring policy and is NOT protection, ` +
278
+ 'because receivers are told to take no action on a message that fails. A domain publishing it ' +
279
+ 'scores below one that enforces.',
280
+ };
281
+ }
282
+ if (policy === 'quarantine' || policy === 'reject') {
283
+ if (!aligned) {
284
+ return {
285
+ grade: 1,
286
+ evidence: `${parts.join('; ')} — an enforcing policy with neither SPF nor DKIM to align against protects nothing yet`,
287
+ };
288
+ }
289
+ if (policy === 'reject' && spf && dkim && rua) {
290
+ return { grade: 3, evidence: `${parts.join('; ')} — enforcing, both mechanisms aligned, and reporting to an rua address` };
291
+ }
292
+ return { grade: 2, evidence: `${parts.join('; ')} — an enforcing DMARC policy with an aligned mechanism` };
293
+ }
294
+ return { grade: 1, evidence: `${parts.join('; ')} — a mail-authenticity record exists, with no enforcing DMARC policy above it` };
295
+ }
296
+
297
+ /** A privacy notice, a retention rule, a consent mechanism, a deletion path. */
298
+ function _privacyObligations(corpus: { text: string; paths: string[] }, ci: string): Scored {
299
+ const notice =
300
+ corpus.paths.some((p) => /(privacy|datenschutz|\bdpa\b|data-processing)/i.test(p)) ||
301
+ /\b(privacy (policy|notice)|Datenschutzerkl)/i.test(corpus.text);
302
+ const retention = /\b(retention (policy|period|schedule)|data retention|storage limitation)\b/i.test(corpus.text);
303
+ const consent = /\b(cookie consent|consent (banner|manager|mode)|cookiebot|usercentrics|klaro|borlabs)\b/i.test(corpus.text);
304
+ const erasure = /\b(right to erasure|data subject request|DSAR|DSR|account deletion|delete my data)\b/i.test(corpus.text);
305
+ const enforced = _ciBlocks(ci, 'privacy', 'gdpr', 'retention', 'consent', 'dsar');
306
+ const seen: string[] = [
307
+ ...(notice ? ['a privacy notice or DPA'] : []),
308
+ ...(retention ? ['a retention rule'] : []),
309
+ ...(consent ? ['a consent mechanism'] : []),
310
+ ...(erasure ? ['an erasure / data-subject-request path'] : []),
311
+ ...(enforced ? ['a blocking CI step naming a privacy obligation'] : []),
312
+ ];
313
+ return {
314
+ grade: _graded(notice || retention || consent || erasure, enforced),
315
+ evidence: seen.length > 0 ? seen.join('; ') : 'no privacy notice, retention rule, consent mechanism or erasure path found',
316
+ };
317
+ }
318
+
152
319
  export function grade(root: string): Matrix {
153
320
  const ci = _ciText(root);
154
321
  const hasCi = ci !== '';
@@ -310,6 +477,41 @@ export function grade(root: string): Matrix {
310
477
  evidence: 'out of scope by design (see the roadmap exclusion table)',
311
478
  });
312
479
 
480
+ // 11. operational recovery — NON-KNOCKOUT
481
+ //
482
+ // New dimensions are never knockouts. A knockout added today would re-bind
483
+ // every existing verdict at L0 on the day it shipped, which changes what the
484
+ // level MEANS while looking like a change to what it measures.
485
+ const corpus = _textCorpus(root);
486
+ const recovery = _recovery(corpus, ci);
487
+ dims.push({
488
+ id: 'operational-recovery',
489
+ label: 'operational recovery',
490
+ knockout: false,
491
+ grade: recovery.grade,
492
+ evidence: recovery.evidence,
493
+ });
494
+
495
+ // 12. mail authenticity — NON-KNOCKOUT, own ladder (4.2)
496
+ const mail = _mailAuthenticity(corpus);
497
+ dims.push({
498
+ id: 'mail-authenticity',
499
+ label: 'mail authenticity',
500
+ knockout: false,
501
+ grade: mail.grade,
502
+ evidence: mail.evidence,
503
+ });
504
+
505
+ // 13. privacy obligations — NON-KNOCKOUT
506
+ const privacy = _privacyObligations(corpus, ci);
507
+ dims.push({
508
+ id: 'privacy-obligations',
509
+ label: 'privacy obligations',
510
+ knockout: false,
511
+ grade: privacy.grade,
512
+ evidence: privacy.evidence,
513
+ });
514
+
313
515
  // ── the verdict: min over knockouts, undetectable binds at L0 ──────────
314
516
  const knockouts = dims.filter((d) => d.knockout);
315
517
  const undetectable = knockouts.find((d) => d.grade === null);
@@ -440,6 +642,23 @@ export function selfTest(): number {
440
642
  return /%|\/100/.test(out) ? 1 : 0;
441
643
  },
442
644
  },
645
+ {
646
+ name: 'DMARC p=none scores strictly below p=quarantine — monitoring is not protection',
647
+ expect: 'accept',
648
+ run: () => {
649
+ const g = (zone: string): number => {
650
+ const d = grade(mk({
651
+ 'dns/zone.txt': zone,
652
+ 'dns/spf.txt': '@ IN TXT "v=spf1 include:_spf.example.net -all"\n',
653
+ 'dns/dkim.txt': 'sel._domainkey IN TXT "v=DKIM1; k=rsa; p=MIIB"\n',
654
+ }));
655
+ return d.dimensions.find((x) => x.id === 'mail-authenticity')?.grade ?? -1;
656
+ };
657
+ const none = g('_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:d@example.com"\n');
658
+ const quarantine = g('_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:d@example.com"\n');
659
+ return none === 1 && quarantine === 2 ? 0 : 1;
660
+ },
661
+ },
443
662
  {
444
663
  name: 'rejects a missing target root rather than grading an empty tree',
445
664
  expect: 'reject',