@event4u/agent-config 14.14.0 → 14.15.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 (72) 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 +63 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +513 -17
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +1043 -390
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +703 -115
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/rule-enforcement-baseline.json +0 -1
  35. package/src/config/web-launch-readiness.json +4 -2
  36. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  37. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  38. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  39. package/src/scripts/_dispatch.bash +11 -0
  40. package/src/scripts/_lib/branch_convergence.ts +186 -0
  41. package/src/scripts/_lib/context_observation.ts +132 -0
  42. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  43. package/src/scripts/_lib/experiment_binding.ts +95 -0
  44. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  45. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  46. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  47. package/src/scripts/_lib/release_highlights.ts +75 -10
  48. package/src/scripts/_lib/repeated_failure.ts +19 -2
  49. package/src/scripts/_lib/runtime_journal.ts +11 -4
  50. package/src/scripts/check_agent_artifact_location.ts +337 -0
  51. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  52. package/src/scripts/check_enforcement_coverage.ts +40 -1
  53. package/src/scripts/check_release_highlights.ts +17 -1
  54. package/src/scripts/check_web_launch_readiness.ts +310 -20
  55. package/src/scripts/evolution_lab.ts +53 -1
  56. package/src/scripts/generate_capability_matrix.ts +16 -9
  57. package/src/scripts/git_authorization_hook.ts +476 -6
  58. package/src/scripts/grade_target_readiness.ts +221 -2
  59. package/src/scripts/hook_effect_doctor.ts +47 -4
  60. package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
  61. package/src/scripts/hooks/merge_impact.ts +207 -0
  62. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  63. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  64. package/src/scripts/install-hooks.sh +18 -0
  65. package/src/scripts/lint_code_comments.ts +106 -1
  66. package/src/scripts/release.ts +5 -17
  67. package/src/scripts/release_drill.ts +76 -1
  68. package/src/scripts/release_env.ts +31 -0
  69. package/src/scripts/release_publication.ts +125 -1
  70. package/src/scripts/roadmap_context.ts +15 -0
  71. package/src/scripts/skill_scout.ts +633 -0
  72. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -21,14 +21,16 @@
21
21
  * (`claim:web-launch-readiness-finds-more`, status `unbacked`). Absent the
22
22
  * opt-in, this exits 0 and says why rather than auditing.
23
23
  *
24
- * Exit codes: 0 = no critical/high finding (or not enabled) · 1 = a blocking
25
- * finding · 2 = usage or a dead scan scope.
24
+ * Exit codes: 0 = no critical/high finding and nothing left undecided (or not
25
+ * enabled) · 1 = a blocking finding, OR an applicable check the instrument could
26
+ * not decide · 2 = usage or a dead scan scope.
26
27
  */
27
28
  import * as fs from 'node:fs';
28
29
  import * as path from 'node:path';
29
30
  import process from 'node:process';
30
31
  import { fileURLToPath } from 'node:url';
31
32
 
33
+ import { asOf } from './_lib/as_of.js';
32
34
  import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
33
35
 
34
36
  const HERE = fileURLToPath(import.meta.url);
@@ -42,7 +44,12 @@ export type Tier = 'critical' | 'high' | 'medium' | 'situational';
42
44
  *
43
45
  * `applies_to` decides WHETHER a check applies; a region decides HOW SEVERELY.
44
46
  * A legal page is not more or less *applicable* in Germany — it is more or less
45
- * *optional*, because TMG 5 and DSGVO Art. 13 make it owed rather than advisable.
47
+ * *optional*, because German and EU law make it owed rather than advisable. The
48
+ * two citations behind that sentence are NOT restated here: they live in the
49
+ * escalation row's `authority` field in `web-launch-readiness.json`, where a
50
+ * `review_by` date can lapse them. That is the whole point of the
51
+ * `ddg-citation-authority` decision — a statute in prose cited law already dead
52
+ * for twenty-seven months and nothing could see it.
46
53
  * Modelling it as a tier escalation keeps one check with one implementation and
47
54
  * puts the jurisdiction where it belongs: on the consequence.
48
55
  */
@@ -56,6 +63,10 @@ export interface CheckDef {
56
63
  why: string;
57
64
  remediation: string;
58
65
  verification: string;
66
+ /** Statute + where it can be read. Required once the row asserts a legal basis. */
67
+ authority?: string;
68
+ /** ISO date the citation must be re-read by. A lapsed date FAILS the loader. */
69
+ review_by?: string;
59
70
  }
60
71
 
61
72
  export interface Finding {
@@ -75,6 +86,22 @@ export interface Skipped {
75
86
  reason: string;
76
87
  }
77
88
 
89
+ /**
90
+ * An applicable check the instrument could not decide.
91
+ *
92
+ * NOT a finding (nothing was established) and NOT a pass (nothing was cleared).
93
+ * The third state exists because a detector that guesses on an undecidable input
94
+ * is worse than one that abstains — and because a pass this checker did not earn
95
+ * is exactly the silent-green defect its own header names.
96
+ */
97
+ export interface Undecided {
98
+ check: string;
99
+ tier: Tier;
100
+ /** `file:line` the instrument stopped at, so the reader can look at it. */
101
+ location: string;
102
+ reason: string;
103
+ }
104
+
78
105
  /** A tier the region axis moved, and why. Reported so the axis is visible. */
79
106
  export interface Escalated {
80
107
  check: string;
@@ -93,6 +120,8 @@ export interface Report {
93
120
  passed: string[];
94
121
  /** Applicable checks with no implementation yet — never counted as passed. */
95
122
  unimplemented: string[];
123
+ /** Applicable, implemented, and undecidable on THIS input. Never a pass. */
124
+ unknown: Undecided[];
96
125
  /** Tiers the region axis raised. Empty on `unspecified`, by construction. */
97
126
  escalated: Escalated[];
98
127
  scanned_files: number;
@@ -103,6 +132,141 @@ export interface Escalation {
103
132
  region: Region;
104
133
  to: Tier;
105
134
  why: string;
135
+ /** Same contract as `CheckDef` — an escalation row is a legal claim too. */
136
+ authority?: string;
137
+ review_by?: string;
138
+ }
139
+
140
+ /* -- 2.1/2.2: a legal claim carries its own authority and expiry ------------ */
141
+
142
+ /**
143
+ * A statute reference: a named instrument, or a numbered article/section.
144
+ *
145
+ * The list is deliberately short and abbreviation-shaped. A broader "any legal
146
+ * word" match would fire on `regulatory exposure` and turn every jurisdictional
147
+ * sentence into a citation the gate then demands a source for — which is how a
148
+ * scoped gate becomes a blanket one nobody can satisfy. The numbered-provision
149
+ * arm (`Art. 13`, `§ 5`) catches an instrument the list does not name yet.
150
+ *
151
+ * `TMG` is retained as a token on purpose, and this is a HISTORICAL NOTE rather
152
+ * than a citation: the statute was superseded by the DDG in May 2024, so a row
153
+ * that names it again is exactly what this detector must still catch.
154
+ */
155
+ export const STATUTE_RE =
156
+ /\b(TMG|DDG|TTDSG|TDDDG|DSGVO|GDPR|BDSG|RDG|UWG|BGB|HGB|StGB|CCPA|CPRA|HIPAA|ePrivacy|DSA|DMA|BFSG|EAA)\b|(?:\bArt\.|\bArticle\b|§)\s*\d/;
157
+
158
+ /** A config row that may assert a legal basis: a check, or a region escalation. */
159
+ export interface LegalRow {
160
+ kind: 'check' | 'escalation';
161
+ id: string;
162
+ why: string;
163
+ authority?: string;
164
+ review_by?: string;
165
+ }
166
+
167
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
168
+
169
+ /**
170
+ * Today, as an ISO date, through the repository's one sanctioned clock seam.
171
+ *
172
+ * NOT `new Date()`: a lapsed-date verdict read off the wall clock is a verdict
173
+ * that greens on Monday and reds on Friday from the same tree, which is the
174
+ * defect `lint_deterministic_time` exists to keep out of a gate. `asOf()`
175
+ * resolves `--as-of`, `AC_AS_OF`, the commit date under CI, and only then the
176
+ * wall clock — with its own non-reproducibility warning attached.
177
+ */
178
+ export function asOfToday(): string {
179
+ return asOf().toISOString().slice(0, 10);
180
+ }
181
+
182
+ /** Every row of the config that could carry a legal claim, in report order. */
183
+ export function legalRowsOf(doc: {
184
+ checks?: CheckDef[];
185
+ regions?: { escalations?: Escalation[] };
186
+ }): LegalRow[] {
187
+ const rows: LegalRow[] = [];
188
+ for (const c of doc.checks ?? []) {
189
+ rows.push({
190
+ kind: 'check',
191
+ id: c.id,
192
+ why: c.why ?? '',
193
+ ...(c.authority === undefined ? {} : { authority: c.authority }),
194
+ ...(c.review_by === undefined ? {} : { review_by: c.review_by }),
195
+ });
196
+ }
197
+ for (const e of doc.regions?.escalations ?? []) {
198
+ rows.push({
199
+ kind: 'escalation',
200
+ id: `${e.check}@${e.region}`,
201
+ why: e.why ?? '',
202
+ ...(e.authority === undefined ? {} : { authority: e.authority }),
203
+ ...(e.review_by === undefined ? {} : { review_by: e.review_by }),
204
+ });
205
+ }
206
+ return rows;
207
+ }
208
+
209
+ /**
210
+ * The legal-row contract, as a list of violations (empty = clean).
211
+ *
212
+ * Three obligations, and the third is the one that makes the other two more
213
+ * than decoration:
214
+ *
215
+ * 1. a row whose `why` NAMES a statute must carry `authority` and `review_by`;
216
+ * 2. `authority` and `review_by` travel together — a citation with no expiry
217
+ * is the comment this contract exists to replace, and an expiry with no
218
+ * citation dates nothing. This half is what keeps the contract intact when
219
+ * the statute moves OUT of the prose and INTO `authority`;
220
+ * 3. a `review_by` on or after `asOf` is live; anything earlier is LAPSED and
221
+ * is a violation rather than a warning. A date nothing reads is a comment,
222
+ * and this repository already documents a warning ignored eighteen times.
223
+ *
224
+ * A row that asserts no legal basis is not policed. The gate is scoped, not
225
+ * blanket: demanding a citation from `A framework default error page loses the
226
+ * visitor` would make the contract unsatisfiable and therefore ignorable.
227
+ */
228
+ export function legalRowViolations(rows: readonly LegalRow[], asOf: string): string[] {
229
+ const out: string[] = [];
230
+ for (const r of rows) {
231
+ const namesStatute = STATUTE_RE.test(r.why);
232
+ const hasAuthority = (r.authority ?? '').trim().length > 0;
233
+ const hasDate = (r.review_by ?? '').trim().length > 0;
234
+ const where = `${r.kind} "${r.id}"`;
235
+ // One violation per MISSING FIELD, never one per rule that noticed it.
236
+ // Dropping `authority` from a statute row trips both obligation 1 and
237
+ // obligation 2, and reporting it twice would make the count a property
238
+ // of the rule set rather than of the config.
239
+ const isLegal = namesStatute || hasAuthority || hasDate;
240
+ if (isLegal && !hasAuthority) {
241
+ out.push(
242
+ `${where}: carries no \`authority\` — ` +
243
+ (namesStatute
244
+ ? 'its `why` names a statute, and a citation with no source is a comment rather than a claim anyone can re-check.'
245
+ : 'a `review_by` with no citation dates nothing.'),
246
+ );
247
+ }
248
+ if (isLegal && !hasDate) {
249
+ out.push(
250
+ `${where}: carries no \`review_by\` — ` +
251
+ (namesStatute
252
+ ? 'its `why` names a statute, and the citation has no date by which it must be re-read.'
253
+ : 'an undated citation cannot go stale visibly.'),
254
+ );
255
+ }
256
+ if (!hasDate) continue;
257
+ const date = (r.review_by ?? '').trim();
258
+ if (!ISO_DATE_RE.test(date) || Number.isNaN(Date.parse(date))) {
259
+ out.push(`${where}: \`review_by\` is "${date}", which is not an ISO YYYY-MM-DD date.`);
260
+ continue;
261
+ }
262
+ if (date < asOf) {
263
+ out.push(
264
+ `${where}: \`review_by\` lapsed on ${date} (as of ${asOf}) — re-read the citation, ` +
265
+ 'then move the date. Overdue is a finding, not a silent pass.',
266
+ );
267
+ }
268
+ }
269
+ return out;
106
270
  }
107
271
 
108
272
  export function loadConfig(root = REPO_ROOT): {
@@ -144,6 +308,15 @@ export function loadConfig(root = REPO_ROOT): {
144
308
  'situational tier for a legally owed page.',
145
309
  );
146
310
  }
311
+ const legal = legalRowViolations(legalRowsOf(doc), asOfToday());
312
+ if (legal.length > 0) {
313
+ // A gate that audited a site while shipping an unbacked legal claim of
314
+ // its own would be auditing from a position it has not earned.
315
+ throw new DeadScopeError(
316
+ 'check_web_launch_readiness',
317
+ `${CONFIG_REL} ships a legal claim the row cannot back:\n - ${legal.join('\n - ')}`,
318
+ );
319
+ }
147
320
  return { checks, siteTypes, regions, escalations };
148
321
  }
149
322
 
@@ -479,17 +652,44 @@ const requiredLegalPages: Impl = (files) => {
479
652
  };
480
653
 
481
654
  /**
482
- * Analytics loads only behind consent.
655
+ * Analytics loads only behind consent — INCLUDING the load order (3.1).
656
+ *
657
+ * The row's own `verification` field asks for the runtime assertion: *"Load the
658
+ * page with consent declined and assert no request to the analytics origin."*
659
+ * A static reader cannot load a page. The strongest assertion it CAN make is
660
+ * source order within one document, so that is what it makes — and where even
661
+ * that is undecidable it returns `unknown` (3.2) rather than a pass.
483
662
  *
484
- * The finding is analytics present with NO consent mechanism anywhere in the
485
- * build — never "analytics present", which would flag every site that does
486
- * consent correctly. The gate cannot see the wiring order, and says so in the
487
- * evidence rather than implying it checked.
663
+ * What the ordering pass covers, stated so the message can say it: the position
664
+ * of the measurement tag relative to the gate script INSIDE one file. What it
665
+ * does not cover: runtime order, deferred or dynamic imports, order established
666
+ * by a bundler across files, and anything a minifier put on one line — each of
667
+ * which produces an `unknown`, never a silent pass.
488
668
  */
489
669
  const ANALYTICS_RE = /(googletagmanager\.com|google-analytics\.com|gtag\(|plausible\.io|matomo|segment\.com|hotjar)/i;
490
670
  const CONSENT_RE = /(consent|cookiebanner|cookie-banner|cookieconsent|klaro|usercentrics|borlabs|osano)/i;
491
- const analyticsAndConsent: Impl = (files) => {
671
+
672
+ /**
673
+ * A line that can actually LOAD something, as opposed to prose about loading.
674
+ *
675
+ * `consent-order-bad` says the word *consent* in its `<meta description>` and
676
+ * still fires: a sentence describing a gate is not a gate, and a positional
677
+ * detector that counted it would report a correctly ordered page while the tag
678
+ * fires first. Applied to markup only — every line of a `.js` file is code.
679
+ */
680
+ const EXECUTABLE_LINE_RE = /<script\b|\bsrc\s*=|\bimport\b|\brequire\s*\(|\.js\b/i;
681
+
682
+ /** Past this, a line is a bundler artefact and its internal order is not source order. */
683
+ const MINIFIED_LINE_CHARS = 400;
684
+
685
+ interface ConsentOrder {
686
+ hits: Located[];
687
+ undecided: Located[];
688
+ }
689
+
690
+ function analyseConsentOrder(files: readonly SourceFile[]): ConsentOrder {
492
691
  const hits: Located[] = [];
692
+ const undecided: Located[] = [];
493
693
  // Prose is excluded from BOTH sides, and the fixture is why: a `.md` file
494
694
  // in the build tree describing the consent banner matched CONSENT_RE, so
495
695
  // the check reported a pass on a fixture seeded to fire it. Documentation
@@ -497,21 +697,92 @@ const analyticsAndConsent: Impl = (files) => {
497
697
  // runs the other way, so an analytics name mentioned in a README must not
498
698
  // count as analytics either.
499
699
  const code = files.filter((f) => !/\.md$/i.test(f.base));
500
- const anyConsent = code.some((f) => CONSENT_RE.test(f.text));
501
- if (anyConsent) return hits;
700
+ const loadable = (f: SourceFile, line: string): boolean => !isPage(f) || EXECUTABLE_LINE_RE.test(line);
701
+ const firstLoad = (f: SourceFile, re: RegExp): number =>
702
+ f.lines.findIndex((l) => re.test(l) && loadable(f, l));
703
+ const anyConsent = code.some((f) => firstLoad(f, CONSENT_RE) >= 0);
704
+
502
705
  for (const f of code) {
503
- f.lines.forEach((line, i) => {
504
- if (!ANALYTICS_RE.test(line)) return;
706
+ const a = firstLoad(f, ANALYTICS_RE);
707
+ if (a < 0) continue;
708
+ if (!anyConsent) {
709
+ // Unchanged from the pre-3.1 behaviour, and still the sharpest
710
+ // finding available: nothing in the build can gate the tag.
711
+ f.lines.forEach((line, i) => {
712
+ if (!ANALYTICS_RE.test(line) || !loadable(f, line)) return;
713
+ hits.push({
714
+ file: f.rel,
715
+ line: i + 1,
716
+ text:
717
+ `${line.trim().slice(0, 120)} — analytics is loaded here and no consent mechanism ` +
718
+ 'was found anywhere in the build, so nothing can gate it. Source order was not the ' +
719
+ 'question: there is no gate to be ordered against.',
720
+ });
721
+ });
722
+ continue;
723
+ }
724
+ const c = firstLoad(f, CONSENT_RE);
725
+ const aLine = f.lines[a] ?? '';
726
+ if (c < 0) {
727
+ undecided.push({
728
+ file: f.rel,
729
+ line: a + 1,
730
+ text:
731
+ 'analytics is loaded here and the consent mechanism lives in another file. Which of ' +
732
+ 'the two the browser runs first is decided by the bundler and the page, not by source ' +
733
+ 'order across files, and a static read cannot establish it.',
734
+ });
735
+ continue;
736
+ }
737
+ if (c === a) {
738
+ undecided.push({
739
+ file: f.rel,
740
+ line: a + 1,
741
+ text:
742
+ 'the analytics tag and the consent gate are on the SAME line, so line order cannot ' +
743
+ 'separate them. Ordering is undecidable on this input.',
744
+ });
745
+ continue;
746
+ }
747
+ if (aLine.length > MINIFIED_LINE_CHARS) {
748
+ undecided.push({
749
+ file: f.rel,
750
+ line: a + 1,
751
+ text:
752
+ `this line is ${String(aLine.length)} characters, i.e. minified — the order inside a ` +
753
+ 'bundler artefact is not the source order this pass reads.',
754
+ });
755
+ continue;
756
+ }
757
+ if (a < c) {
505
758
  hits.push({
506
759
  file: f.rel,
507
- line: i + 1,
760
+ line: a + 1,
508
761
  text:
509
- `${line.trim().slice(0, 120)} — analytics present and no consent mechanism ` +
510
- 'found anywhere in the build. Load order is NOT checked here.',
762
+ `${aLine.trim().slice(0, 120)} — the analytics tag is at line ${String(a + 1)}, ` +
763
+ `ABOVE the consent gate at line ${String(c + 1)} of the same file, so it is reached ` +
764
+ 'before consent can be recorded. Source order inside this file is what was checked; ' +
765
+ 'runtime order, dynamic imports and cross-file bundler order were not.',
511
766
  });
512
- });
767
+ }
513
768
  }
514
- return hits;
769
+ return { hits, undecided };
770
+ }
771
+
772
+ const analyticsAndConsent: Impl = (files) => analyseConsentOrder(files).hits;
773
+
774
+ /**
775
+ * Checks that can report `unknown`, and the probe that decides when they do.
776
+ *
777
+ * A separate table rather than a widened `Impl` signature: the invariant that an
778
+ * EMPTY `Located[]` means "applied and found nothing" is what keeps the
779
+ * silent-green defect out of `IMPLS`, and folding a third state into that return
780
+ * type would put the invariant back in play for all eight implementations.
781
+ */
782
+ type Undecide = (files: readonly SourceFile[]) => Located[];
783
+
784
+ export const UNDECIDABLE: Readonly<Record<string, Undecide>> = {
785
+ 'analytics-and-consent-wiring': (files) => analyseConsentOrder(files).undecided,
515
786
  };
516
787
 
517
788
  /** First line containing `needle`, 1-indexed; 1 when absent (an absence has no line). */
@@ -560,6 +831,7 @@ export function audit(
560
831
  const skipped: Skipped[] = [];
561
832
  const passed: string[] = [];
562
833
  const unimplemented: string[] = [];
834
+ const unknown: Undecided[] = [];
563
835
  const escalated: Escalated[] = [];
564
836
  // ONE walk for every check. Eight walkers over the same bytes is eight
565
837
  // times the IO and eight chances to disagree about what counts as a file.
@@ -586,7 +858,11 @@ export function audit(
586
858
  impl === undefined
587
859
  ? scanIndexability(buildDir).hits
588
860
  : impl(files);
589
- if (hits.length === 0) {
861
+ const undecided = UNDECIDABLE[c.id]?.(files) ?? [];
862
+ for (const u of undecided) {
863
+ unknown.push({ check: c.id, tier, location: `${u.file}:${String(u.line)}`, reason: u.text });
864
+ }
865
+ if (hits.length === 0 && undecided.length === 0) {
590
866
  passed.push(c.id);
591
867
  continue;
592
868
  }
@@ -609,6 +885,7 @@ export function audit(
609
885
  skipped,
610
886
  passed,
611
887
  unimplemented,
888
+ unknown,
612
889
  escalated,
613
890
  scanned_files: files.length,
614
891
  };
@@ -637,6 +914,15 @@ export function render(r: Report): string {
637
914
  out.push(` verification: ${x.verification}`);
638
915
  }
639
916
  }
917
+ if (r.unknown.length > 0) {
918
+ // Printed with the findings rather than with the passes, because an
919
+ // undecided check is an open question about the site — not a clearance.
920
+ out.push('', `UNDECIDED — applicable, audited, and not decidable from this build (${String(r.unknown.length)})`);
921
+ for (const u of r.unknown) {
922
+ out.push(` ${u.location} ${u.check}`);
923
+ out.push(` unknown: ${u.reason}`);
924
+ }
925
+ }
640
926
  if (r.passed.length > 0) out.push('', `PASSED: ${r.passed.join(', ')}`);
641
927
  if (r.unimplemented.length > 0) {
642
928
  out.push('', `NOT YET IMPLEMENTED (applicable, not audited): ${r.unimplemented.join(', ')}`);
@@ -697,7 +983,11 @@ export function main(argv: string[] = process.argv.slice(2), root = REPO_ROOT):
697
983
  process.stdout.write(`${render(r)}\n`);
698
984
  }
699
985
  const blocking = r.findings.filter((f) => f.tier === 'critical' || f.tier === 'high');
700
- return blocking.length > 0 ? 1 : 0;
986
+ // An UNDECIDED applicable check exits non-zero whatever its tier. Exiting 0
987
+ // would report the run as clean on a question the instrument never answered,
988
+ // which is the same silent-green failure as counting an unimplemented check
989
+ // as passed — the tier axis grades findings, and this is not one.
990
+ return blocking.length > 0 || r.unknown.length > 0 ? 1 : 0;
701
991
  }
702
992
 
703
993
  if (path.resolve(process.argv[1] ?? '') === path.resolve(HERE)) {
@@ -89,6 +89,8 @@ import {
89
89
  } from './_lib/promotion_evidence.js';
90
90
  import { MERGE_AUTHORITY_ROADMAP, readMergeAuthorityStatus } from './_lib/promotion_capability.js';
91
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';
92
94
  import {
93
95
  RECIPES,
94
96
  byteCompare,
@@ -635,6 +637,15 @@ function guardAbort(e: unknown): number | undefined {
635
637
  );
636
638
  return EXIT_GUARD_ABORT;
637
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
+ }
638
649
  if (e instanceof BudgetConfigError) {
639
650
  process.stderr.write(`evolution_lab: ABORTED — ${(e as Error).message}\n`);
640
651
  return EXIT_GUARD_ABORT;
@@ -767,10 +778,38 @@ function verbRun(argv: readonly string[]): number {
767
778
  return fail(`vector ${vf} rejected — ${(e as Error).message}`);
768
779
  }
769
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
+
770
809
  const seen = new Set<string>();
771
810
  const ids: string[] = [];
772
811
  const results: CascadeResult[] = [];
773
- for (const f of files.sort(byteCompare)) {
812
+ for (const f of ordered) {
774
813
  let record: CandidateRecord;
775
814
  try {
776
815
  record = loadRecord(f);
@@ -821,6 +860,19 @@ function verbRun(argv: readonly string[]): number {
821
860
  results.push(result);
822
861
  }
823
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
+
824
876
  for (const r of results) {
825
877
  if (r.outcome === 'abort') {
826
878
  process.stdout.write(
@@ -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
  );