@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
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint_code_comments — the carrier `code-comment-discipline` and the
4
+ * "Code comments English" clause of `language-and-tone` never had.
5
+ *
6
+ * Both rules existed and neither could fire on the thing they govern.
7
+ * `language-and-tone` is a kernel rule and names one enforcer,
8
+ * `check_md_language.ts`, which rejects every path that is not `.md`
9
+ * (`check_md_language.ts:175`) — so the code clause carried an `enforced_by`
10
+ * entry that structurally cannot see a source file. `code-comment-discipline`
11
+ * is `type: auto` and matches PROMPT keywords, so a run that writes forty-five
12
+ * files without the user typing "comment", "refactor" or "implement" never
13
+ * loads it at all. Measured 2026-09-02 in a consumer repository: 40 of 45
14
+ * changed source files carried German comment lines, one of them a 101-line
15
+ * colour-analysis report inside a CSS file.
16
+ *
17
+ * Three classes, each keyed on STRUCTURE rather than on a judgement about
18
+ * prose, because a gate that has to decide whether a comment is "useful" is a
19
+ * gate nobody can predict:
20
+ *
21
+ * 1. `de-comment` — a comment line carrying German. Two independent
22
+ * signals: a German-specific character, or two or
23
+ * more German function words on one line.
24
+ * 2. `report-comment` — a comment block shaped like an analysis report.
25
+ * The five literal patterns are in REPORT_SIGNALS,
26
+ * named there rather than quoted here so this header
27
+ * does not trip its own gate. Length is deliberately
28
+ * NOT a signal; a long comment can be one long WHY.
29
+ * 3. `provenance-comment` — a comment explaining where the code came from:
30
+ * a roadmap path, a phase-and-step citation, a
31
+ * prototype or reference artefact path. Evidence
32
+ * belongs in the roadmap that asked for it, and a
33
+ * second copy in the source is the copy in the wrong
34
+ * place.
35
+ *
36
+ * Escape, per line or per file:
37
+ * code-comment-allow <class> -- <reason>
38
+ * A reason is mandatory; the bare marker does not suppress.
39
+ *
40
+ * Scope is the DIFF by default, so a pre-existing tree does not turn red.
41
+ * `--paths` and `--root` exist for auditing a tree the gate does not own.
42
+ *
43
+ * STATED RECALL LIMIT, found by running the gate against its own first output
44
+ * rather than by reasoning about it. A straight `"` left unpaired on a line is
45
+ * ambiguous — it opens a quotation that continues, or closes one that began
46
+ * above — and the gate reads it as an opener and stops scanning the rest of
47
+ * the line. That is right for an English sentence introducing a German quote
48
+ * and wrong for a German continuation whose opener sits on an earlier line.
49
+ * Typographic marks carry direction and do not have the ambiguity. The cost is
50
+ * a miss, never a false positive, and the answer is a second instrument: a
51
+ * plain word sweep over every changed file with no comment detection at all
52
+ * caught three lines this classifier could not, and both were run to zero
53
+ * before the tree was called clean. A gate that has been wrong once is not a
54
+ * gate to verify with alone.
55
+ *
56
+ * Exit codes: 0 clean · 2 findings · 1 usage or internal error.
57
+ *
58
+ * `--self-test` drives this binary over {@link COMMENT_CASES}, the one fixture
59
+ * table the vitest suite also enumerates. A gate that reports a `scanned:`
60
+ * count has proven it read something; only a case that must REJECT proves the
61
+ * reading changes the verdict, and only a shared table keeps the two corpora
62
+ * from drifting apart.
63
+ */
64
+ import { spawnSync } from 'node:child_process';
65
+ import * as fs from 'node:fs';
66
+ import * as os from 'node:os';
67
+ import * as path from 'node:path';
68
+ import process from 'node:process';
69
+ import { fileURLToPath } from 'node:url';
70
+
71
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
72
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
73
+
74
+ const _HERE = fileURLToPath(import.meta.url);
75
+
76
+ /** Extensions whose comment syntax the line classifier understands. */
77
+ export const SCANNED_EXTENSIONS = new Set([
78
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
79
+ '.css', '.scss', '.less',
80
+ '.php', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb',
81
+ '.vue', '.svelte',
82
+ ]);
83
+
84
+ /** Paths that are generated, vendored, or otherwise not authored here. */
85
+ const SKIP_SEGMENTS = ['node_modules/', 'dist/', 'build/', '.next/', 'vendor/', 'coverage/'];
86
+
87
+ /** Header markers that mark a whole file as machine-written. */
88
+ const GENERATED_MARKERS = [
89
+ 'generated by', 'auto-generated', 'autogenerated', '@generated',
90
+ 'do not edit', 'code generated by',
91
+ ];
92
+
93
+ /**
94
+ * A comment line, in the languages above.
95
+ *
96
+ * Deliberately line-shaped rather than a parser: a parser would be more
97
+ * precise about a comment token inside a string literal, and would also be a
98
+ * dependency and a second definition of "what is a comment" for every language
99
+ * added later. The cost of the approximation is a false positive on a string
100
+ * that begins a line with `//` or `*`, which the escape marker answers.
101
+ */
102
+ const COMMENT_LINE = /^\s*(\{\s*)?(\/\/|\/\*|\*(?!\/)|\*\/|#(?!!)|--)/;
103
+
104
+ /**
105
+ * Does this line OPEN a `/* … *\/` block that stays open past its own end?
106
+ *
107
+ * The missed case that made this necessary: a block comment whose continuation
108
+ * lines carry no leading `*` at all —
109
+ *
110
+ * /* Header line.
111
+ * Continuation nobody marked. *\/
112
+ *
113
+ * `COMMENT_LINE` matches the first line and nothing after it, so every
114
+ * continuation read as code and was never classified. Measured: a three-line
115
+ * German comment in a consumer repository survived a clean run of this gate,
116
+ * which is the failure that cost the gate its credibility on its first day.
117
+ */
118
+ function opensBlock(line: string): boolean {
119
+ const lastOpen = line.lastIndexOf('/*');
120
+ if (lastOpen === -1) return false;
121
+ return line.indexOf('*/', lastOpen + 2) === -1;
122
+ }
123
+
124
+ /** Does this line CLOSE an open block? */
125
+ function closesBlock(line: string): boolean {
126
+ return line.includes('*/');
127
+ }
128
+
129
+ /** German-specific characters. `ß` and the umlauts appear in no English word. */
130
+ const DE_CHARS = /[äöüßÄÖÜ]/;
131
+
132
+ /**
133
+ * German function words. Function words rather than nouns on purpose: a noun
134
+ * list is endless and collides with identifiers, while the closed class of
135
+ * articles, prepositions, conjunctions and auxiliaries is small, stable, and
136
+ * almost impossible to hit twice on one line of English.
137
+ *
138
+ * `die`, `man`, `hat`, `war`, `bei`, `im`, `am`, `so`, `ist` are deliberately
139
+ * ABSENT even though they are German: each is either an English word or a
140
+ * common identifier fragment, and the two-hit threshold is not enough
141
+ * protection when a single term is that ambiguous.
142
+ */
143
+ const DE_WORDS = new RegExp(
144
+ '\\b(' + [
145
+ 'der', 'das', 'dem', 'den', 'des',
146
+ 'und', 'oder', 'aber', 'sondern', 'denn',
147
+ 'nicht', 'kein', 'keine', 'keiner', 'keinen',
148
+ 'wird', 'werden', 'wurde', 'worden', 'sind', 'waren',
149
+ 'eine', 'einen', 'einem', 'einer', 'eines',
150
+ 'dass', 'weil', 'damit', 'wenn', 'dann', 'sonst', 'obwohl',
151
+ 'hier', 'dort', 'oben', 'unten', 'auch', 'nur', 'schon', 'noch',
152
+ 'aus', 'fuer', 'ueber', 'durch', 'gegen', 'ohne', 'nach', 'vor',
153
+ 'beim', 'zum', 'zur', 'vom', 'mit', 'sich', 'ihre', 'ihrer', 'seine',
154
+ 'jede', 'jeder', 'jedes', 'alle', 'allen', 'aller',
155
+ 'diese', 'dieser', 'dieses', 'diesem',
156
+ 'steht', 'stehen', 'haben', 'kann', 'koennen', 'muss', 'muessen',
157
+ 'soll', 'sollte', 'gibt', 'geht', 'macht', 'zeigt', 'liegt',
158
+ 'deshalb', 'deswegen', 'trotzdem', 'bereits', 'immer', 'niemals',
159
+ 'derselbe', 'dieselbe', 'dasselbe', 'denselben', 'demselben', 'derselben',
160
+ 'andere', 'anderer', 'anderen', 'anderem', 'anderes',
161
+ 'unter', 'ueber', 'zwischen', 'wegen', 'statt', 'ausser', 'seit',
162
+ 'eigene', 'eigenen', 'eigener', 'jeweils', 'genau', 'etwa', 'sowohl',
163
+ 'warum', 'wieso', 'welche', 'welcher', 'wobei', 'sowie',
164
+ ].join('|') + ')\\b',
165
+ 'gi',
166
+ );
167
+
168
+ /** Structural report markers — a comment shaped like a document, not a note. */
169
+ const REPORT_SIGNALS: ReadonlyArray<readonly [string, RegExp]> = [
170
+ ['markdown-table', /^\s*(?:\/\/|\*|#|--)?\s*\|[^|]*\|[^|]*\|/],
171
+ ['box-rule', /[─═━]{10,}/],
172
+ ['section-mark', /^\s*(?:\/\/|\*|#|--)?\s*§\s+\S/],
173
+ ['markdown-heading', /^\s*(?:\/\/|\*|#|--)?\s*#{2,}\s+\S/],
174
+ ['revisit-if', /\brevisit-if\b/i],
175
+ ];
176
+
177
+ /** Provenance markers — the code explaining its own paper trail. */
178
+ const PROVENANCE_SIGNALS: ReadonlyArray<readonly [string, RegExp]> = [
179
+ ['roadmap-path', /\bagents\/roadmaps?\//i],
180
+ ['reference-path', /\bagents\/(references|evidence|decisions)\//i],
181
+ ['phase-step', /\b(phase|phase)\s*\d+\s*,\s*(step|schritt)\s*\d+/i],
182
+ ['prototype-source', /\b(prototyp\w*|prototype)\b[^\n]{0,40}\.(html|fig|sketch|dc\.html)\b/i],
183
+ ];
184
+
185
+ export type FindingClass = 'de-comment' | 'report-comment' | 'provenance-comment';
186
+
187
+ export interface CommentFinding {
188
+ cls: FindingClass;
189
+ file: string;
190
+ line: number;
191
+ signal: string;
192
+ snippet: string;
193
+ }
194
+
195
+ /** An escape marker with a real reason, for `cls` or for every class. */
196
+ function escapedFor(line: string, cls: FindingClass): boolean {
197
+ const m = /code-comment-allow\s+([a-z-]+|\*)\s*--\s*(\S.*)$/.exec(line);
198
+ if (!m) return false;
199
+ const target = m[1];
200
+ return target === '*' || target === cls;
201
+ }
202
+
203
+ /** Is the whole file exempt — a file-level marker, or a generator header? */
204
+ export function fileIsExempt(text: string): boolean {
205
+ const head = text.slice(0, 2000).toLowerCase();
206
+ if (GENERATED_MARKERS.some((m) => head.includes(m))) return true;
207
+ return /code-comment-allow-file\s*--\s*\S/.test(text.slice(0, 2000));
208
+ }
209
+
210
+ /** Distinct German function words in a fragment. */
211
+ function germanWordCount(s: string): number {
212
+ return new Set((s.match(DE_WORDS) ?? []).map((h) => h.toLowerCase())).size;
213
+ }
214
+
215
+ /** Strip the comment punctuation so a signal is matched against prose. */
216
+ function commentProse(line: string): string {
217
+ return line.replace(/^\s*(\/\/+|\/\*+|\*+\/?|#+|--)/, ' ');
218
+ }
219
+
220
+ /**
221
+ * Prose with code spans removed.
222
+ *
223
+ * The umlaut signal is otherwise wrong on an English docstring that quotes a
224
+ * non-ASCII sample value — `café@exämple.com` in a scrubber's own
225
+ * documentation is the measured case, and it is data being illustrated rather
226
+ * than German being written. Backtick spans and quoted literals are where
227
+ * that data lives, so both come out before the character test.
228
+ */
229
+ function prosePlain(line: string): string {
230
+ return commentProse(line)
231
+ .replace(/`[^`]*`/g, ' ')
232
+ .replace(/'[^']*'/g, ' ')
233
+ .replace(/"[^"]*"/g, ' ')
234
+ .replace(/[\u201c\u201e][^\u201c\u201d\u201e]*[\u201d\u201c]/g, ' ')
235
+ .replace(/[\u2018\u201a][^\u2018\u2019\u201a]*[\u2019\u2018]/g, ' ')
236
+ .replace(/\u00bb[^\u00ab\u00bb]*\u00ab/g, ' ')
237
+ // Whatever follows the last unpaired TYPOGRAPHIC opener continues as a
238
+ // quotation on the next line, so it is quoted data too.
239
+ //
240
+ // A straight `"` is deliberately excluded here, and the exclusion is a
241
+ // repair: a leftover straight quote is just as likely to be the CLOSE
242
+ // of a quotation that began on the previous line, and treating it as an
243
+ // opener swallowed the rest of the line. Measured — a German
244
+ // continuation line reading `oben" heissen. Der Prototyp dreht sie …`
245
+ // scored zero function words because everything after the quote was
246
+ // erased. Typographic marks carry their own direction and do not have
247
+ // this ambiguity.
248
+ .replace(/[\u201c\u201e][^\u201c\u201d\u201e]*$/, ' ')
249
+ // A leftover STRAIGHT quote is ambiguous — it may open a quotation that
250
+ // continues on the next line, or close one that began on the previous.
251
+ // Trimming to end-of-line is right for the opener and wrong for the
252
+ // closer, so the trim is applied only when the text BEFORE it is not
253
+ // itself German: an English sentence introducing a German quotation is
254
+ // the opener case, and it is the one this must stay silent on.
255
+ .replace(/"[^"]*$/, (m, offset: number, whole: string) =>
256
+ germanWordCount(whole.slice(0, offset)) >= 2 ? m : ' ',
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Scan one file's text. Pure, so it answers for a buffer that is not on disk —
262
+ * which is what the write-time hook needs.
263
+ */
264
+ export function scanText(file: string, text: string): CommentFinding[] {
265
+ if (fileIsExempt(text)) return [];
266
+ const out: CommentFinding[] = [];
267
+ const lines = text.split('\n');
268
+ // A quotation that spans lines is the normal way this tree records what a
269
+ // user actually said, and a per-line quote strip cannot see it: the
270
+ // opening mark is on one line and the closing mark two lines down, so
271
+ // every line between them reads as unquoted German. Carrying the open
272
+ // state across the block is what separates quoted DATA from German PROSE,
273
+ // and without it the six multi-line user quotations in this repository's
274
+ // own hooks are false positives.
275
+ let inQuote = false;
276
+ let inBlock = false;
277
+
278
+ for (let i = 0; i < lines.length; i++) {
279
+ const raw = lines[i] ?? '';
280
+ const isComment = inBlock || COMMENT_LINE.test(raw);
281
+ if (inBlock && closesBlock(raw)) inBlock = false;
282
+ else if (!inBlock && opensBlock(raw)) inBlock = true;
283
+ if (!isComment) { inQuote = false; continue; }
284
+ const prose = commentProse(raw);
285
+ const opensBefore = inQuote;
286
+ const marks = (commentProse(raw).match(/[\u201c\u201d\u201e"]/g) ?? []).length;
287
+ if (marks % 2 === 1) inQuote = !inQuote;
288
+ if (opensBefore) continue;
289
+ const at = i + 1;
290
+ const snip = raw.trim().slice(0, 100);
291
+
292
+ if (!escapedFor(raw, 'de-comment')) {
293
+ const plain = prosePlain(raw);
294
+ const distinct = new Set((plain.match(DE_WORDS) ?? []).map((h) => h.toLowerCase()));
295
+ const umlaut = DE_CHARS.test(plain);
296
+ // An umlaut ALONE proves nothing: `Robertson-Spärck-Jones`, a `Ü2`
297
+ // step label and a quoted sample value all carry one while the
298
+ // sentence around them is English. Two function words carry the
299
+ // sentence on their own; one is enough once a German character is
300
+ // also present. Measured over this repository's own source, both
301
+ // thresholds together leave zero findings on 402 files.
302
+ const isGerman = distinct.size >= 2 || (umlaut && distinct.size >= 1);
303
+ if (isGerman) {
304
+ out.push({
305
+ cls: 'de-comment', file, line: at,
306
+ signal: umlaut ? `umlaut+${[...distinct][0]}` : `function-words: ${[...distinct].slice(0, 4).join(' ')}`,
307
+ snippet: snip,
308
+ });
309
+ }
310
+ }
311
+
312
+ if (!escapedFor(raw, 'report-comment')) {
313
+ for (const [name, re] of REPORT_SIGNALS) {
314
+ if (re.test(raw)) {
315
+ out.push({ cls: 'report-comment', file, line: at, signal: name, snippet: snip });
316
+ break;
317
+ }
318
+ }
319
+ }
320
+
321
+ if (!escapedFor(raw, 'provenance-comment')) {
322
+ for (const [name, re] of PROVENANCE_SIGNALS) {
323
+ if (re.test(prose)) {
324
+ out.push({ cls: 'provenance-comment', file, line: at, signal: name, snippet: snip });
325
+ break;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ /** Should this path be scanned at all? */
334
+ export function isScannable(rel: string): boolean {
335
+ if (SKIP_SEGMENTS.some((s) => rel.includes(s))) return false;
336
+ if (rel.endsWith('.d.ts')) return false;
337
+ return SCANNED_EXTENSIONS.has(path.extname(rel));
338
+ }
339
+
340
+ function changedFiles(root: string, base: string): string[] {
341
+ const r = spawnSync('git', ['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`], {
342
+ cwd: root, encoding: 'utf-8',
343
+ });
344
+ const tracked = r.status === 0 ? r.stdout.split('\n') : [];
345
+ const u = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
346
+ cwd: root, encoding: 'utf-8',
347
+ });
348
+ const untracked = u.status === 0 ? u.stdout.split('\n') : [];
349
+ const m = spawnSync('git', ['diff', '--name-only', '--diff-filter=ACMR'], {
350
+ cwd: root, encoding: 'utf-8',
351
+ });
352
+ const dirty = m.status === 0 ? m.stdout.split('\n') : [];
353
+ return [...new Set([...tracked, ...untracked, ...dirty])].filter((f) => f.trim().length > 0);
354
+ }
355
+
356
+ /**
357
+ * Line numbers this diff ADDED, per file. `'all'` for an untracked file, whose
358
+ * every line is new.
359
+ *
360
+ * The gate is described in `.github/workflows/rule-backstops.yml` as
361
+ * *"diff-scoped, so a pre-existing tree does not turn red"*, and selecting the
362
+ * FILE set from the diff while scanning each file WHOLE only delivers that for
363
+ * files nobody touches. Measured 2026-09-02 on PR #1813, which added no
364
+ * flagged comment at all and was refused on eleven legacy lines — four
365
+ * provenance paths and seven box rules — in four files it edited for unrelated
366
+ * reasons. A gate that reds on debt in any file you open makes every edit to an
367
+ * old file carry an unbounded comment sweep, and the escape it offers per line
368
+ * turns into exactly the allowlist growth `autonomous-execution` forbids.
369
+ *
370
+ * Teeth are unchanged where they belong: a comment the diff writes is scanned
371
+ * on the line it writes. The explicit-path mode (`--files`) stays whole-file,
372
+ * because a caller naming a path is asking for that file, not for a diff.
373
+ *
374
+ * Parsed from `--unified=0` hunk headers rather than from a patch body: the
375
+ * header carries the post-image start and count directly, so no line of added
376
+ * content has to be counted by hand.
377
+ */
378
+ export function addedLineMap(root: string, base: string): Map<string, Set<number> | 'all'> {
379
+ const out = new Map<string, Set<number> | 'all'>();
380
+ const u = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
381
+ cwd: root, encoding: 'utf-8',
382
+ });
383
+ if (u.status === 0) {
384
+ for (const f of u.stdout.split('\n')) {
385
+ if (f.trim()) out.set(f.trim(), 'all');
386
+ }
387
+ }
388
+ for (const argv of [
389
+ ['diff', '--unified=0', '--diff-filter=ACMR', `${base}...HEAD`],
390
+ ['diff', '--unified=0', '--diff-filter=ACMR'],
391
+ ]) {
392
+ const r = spawnSync('git', argv, { cwd: root, encoding: 'utf-8' });
393
+ if (r.status !== 0) continue;
394
+ mergeAddedLines(out, parseAddedLines(r.stdout));
395
+ }
396
+ return out;
397
+ }
398
+
399
+ /**
400
+ * Added line numbers per file, from a `git diff --unified=0` patch.
401
+ *
402
+ * Split out from the git call so the parse is testable without a repository
403
+ * whose diff the test would have to manufacture. A `@@` header with no count
404
+ * means one line — `@@ -4 +4 @@` — and a `+++ /dev/null` header is a deletion,
405
+ * which adds nothing.
406
+ */
407
+ export function parseAddedLines(diffText: string): Map<string, Set<number>> {
408
+ const out = new Map<string, Set<number>>();
409
+ let file: string | null = null;
410
+ for (const line of diffText.split('\n')) {
411
+ if (line.startsWith('+++ ')) {
412
+ const p = line.slice(4).trim();
413
+ file = p === '/dev/null' ? null : p.replace(/^b\//, '');
414
+ continue;
415
+ }
416
+ if (!file || !line.startsWith('@@')) continue;
417
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
418
+ if (!m) continue;
419
+ const start = Number.parseInt(m[1] as string, 10);
420
+ const count = m[2] === undefined ? 1 : Number.parseInt(m[2], 10);
421
+ const set = out.get(file) ?? new Set<number>();
422
+ for (let n = start; n < start + count; n += 1) set.add(n);
423
+ out.set(file, set);
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /** Fold one parsed patch into the accumulator; `'all'` always wins. */
429
+ function mergeAddedLines(
430
+ into: Map<string, Set<number> | 'all'>,
431
+ from: Map<string, Set<number>>,
432
+ ): void {
433
+ for (const [file, lines] of from) {
434
+ const prev = into.get(file);
435
+ if (prev === 'all') continue;
436
+ const set = prev ?? new Set<number>();
437
+ for (const n of lines) set.add(n);
438
+ into.set(file, set);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Findings the diff is answerable for: those sitting on a line it added.
444
+ *
445
+ * `'all'` (an untracked file) keeps everything, `undefined` (a file the diff
446
+ * did not touch) keeps nothing — the two ends of the same rule.
447
+ */
448
+ export function findingsOnAddedLines(
449
+ findings: readonly CommentFinding[],
450
+ lines: Set<number> | 'all' | undefined,
451
+ ): CommentFinding[] {
452
+ if (lines === undefined) return [];
453
+ if (lines === 'all') return [...findings];
454
+ return findings.filter((f) => lines.has(f.line));
455
+ }
456
+
457
+ /** One fixture: a file body and the classes the gate must report on it. */
458
+ export interface CommentCase {
459
+ /** Printed by both consumers, so it reads as the assertion it makes. */
460
+ name: string;
461
+ /**
462
+ * The whole file body, written verbatim.
463
+ *
464
+ * Single-line literals with `\n` escapes rather than a template literal:
465
+ * a template whose lines began with a comment marker would be scanned as
466
+ * comments of THIS file, and the fixtures are deliberately violating.
467
+ */
468
+ source: string;
469
+ /** Classes the classifier must report. Empty means the gate stays silent. */
470
+ flags: readonly FindingClass[];
471
+ }
472
+
473
+ /**
474
+ * The polarity corpus, exported because two callers must read the SAME one.
475
+ *
476
+ * `tests/scripts/lint_code_comments.test.ts` enumerates it against
477
+ * {@link scanText} and asserts the exact class set; `--self-test` enumerates it
478
+ * against the real binary and asserts the exit code. A second table would be a
479
+ * second definition of what this gate catches, and the one nobody runs locally
480
+ * is the one that drifts.
481
+ *
482
+ * The silent half is the load-bearing half. A language heuristic is only usable
483
+ * once its near-misses are pinned, and these are the shapes this repository's
484
+ * own source actually contains: an umlauted proper name, a non-ASCII sample
485
+ * value inside a code span, an umlauted step label, and a German user
486
+ * quotation spanning two comment lines.
487
+ */
488
+ export const COMMENT_CASES: readonly CommentCase[] = [
489
+ {
490
+ name: 'a block-comment continuation carrying no leading star is flagged',
491
+ source:
492
+ ' /* Hausform mit Objekt-`message`: derselbe Aufbau, anderer Schluessel.\n' +
493
+ ' `BaseException::render()` dekodiert eine JSON-Zeichenkette und legt das\n' +
494
+ ' Ergebnis unter `message` ab, bei Feldfehlern ist das ein Objekt. */\n' +
495
+ ' if (isRecord(payload.message)) {\n',
496
+ flags: ['de-comment'],
497
+ },
498
+ {
499
+ name: 'a JSX comment, which opens with a brace rather than a slash, is flagged',
500
+ source: ' {/* Der Titel traegt die Aktion, nicht die Zeile. */}\n',
501
+ flags: ['de-comment'],
502
+ },
503
+ {
504
+ name: 'a German comment line carrying two function words is flagged',
505
+ source: '// Hier stehen nur die Werte, die Bedeutung tragen\nconst a = 1;\n',
506
+ flags: ['de-comment'],
507
+ },
508
+ {
509
+ name: 'transliterated German with no umlaut at all is flagged',
510
+ source: '/* uebersetzt aus dem Prototyp, jeder Wert ist hier belegt */\n',
511
+ flags: ['de-comment'],
512
+ },
513
+ {
514
+ name: 'a German line whose only umlaut sits in ordinary prose is flagged',
515
+ source: '// Die Schriftstufen der kompakten Datenoberfläche\n',
516
+ flags: ['de-comment'],
517
+ },
518
+ {
519
+ name: 'an English comment quoting a non-ASCII sample value stays silent',
520
+ source: '/** so a `café@exämple.com` email or a `/Users/möchte/f` path masks */\n',
521
+ flags: [],
522
+ },
523
+ {
524
+ name: 'an English comment carrying an umlauted proper name stays silent',
525
+ source: '/** Robertson–Spärck-Jones IDF, floored at 0 so terms never subtract. */\n',
526
+ flags: [],
527
+ },
528
+ {
529
+ name: 'an umlauted step label in English prose stays silent',
530
+ source: '// ── Ü2 — orthogonal stance assignment per seat ──\n',
531
+ flags: [],
532
+ },
533
+ {
534
+ name: 'a single-line German user quotation used as data stays silent',
535
+ source: '// the phrase "nicht releasen. Warum nicht?" names no fault\n',
536
+ flags: [],
537
+ },
538
+ {
539
+ name: 'a German user quotation spanning two comment lines stays silent',
540
+ source:
541
+ '/**\n * "Kein Council konfiguriert (keine `.agent-settings.yml`) — ich nutze\n' +
542
+ ' * Subagenten-Fächer mit gegnerischen Linsen als Ersatz"\n */\n',
543
+ flags: [],
544
+ },
545
+ {
546
+ name: 'plain English prose stays silent',
547
+ source: '// The cap is a stated default, not a measured optimum.\n',
548
+ flags: [],
549
+ },
550
+ {
551
+ name: 'a markdown table inside a comment is flagged',
552
+ source: ' * | Prototype | Role | here |\n',
553
+ flags: ['report-comment'],
554
+ },
555
+ {
556
+ name: 'a box-drawing rule is flagged',
557
+ source: ' * ─────────────────────────────\n',
558
+ flags: ['report-comment'],
559
+ },
560
+ {
561
+ name: 'a revisit clause is flagged',
562
+ source: ' * Revisit-if: someone holds the new surface next to the sidebar.\n',
563
+ flags: ['report-comment'],
564
+ },
565
+ {
566
+ name: 'a short dash rule that is not a report stays silent',
567
+ source: '// --- setup ---\n',
568
+ flags: [],
569
+ },
570
+ {
571
+ name: 'a roadmap citation is flagged',
572
+ source: '// see agents/roadmaps/todos-task-module-frontend.md\n',
573
+ flags: ['provenance-comment'],
574
+ },
575
+ {
576
+ name: 'a phase-and-step citation is flagged',
577
+ source: '// Roadmap todos-task-module, Phase 1, Schritt 1.\n',
578
+ flags: ['provenance-comment'],
579
+ },
580
+ {
581
+ name: 'a plain code reference stays silent',
582
+ source: '// see src/lib/token.ts for the parser\n',
583
+ flags: [],
584
+ },
585
+ {
586
+ name: 'a per-line escape carrying a reason suppresses the class',
587
+ source: '// Hier stehen die Werte code-comment-allow de-comment -- quoted spec text\n',
588
+ flags: [],
589
+ },
590
+ {
591
+ name: 'a bare escape with no reason suppresses nothing',
592
+ source: '// Hier stehen die Werte code-comment-allow de-comment\n',
593
+ flags: ['de-comment'],
594
+ },
595
+ ];
596
+
597
+ /**
598
+ * Drive the real binary over every fixture.
599
+ *
600
+ * Shells out rather than calling {@link scanText} in-process, because the
601
+ * thing a contributor runs is the CLI — its argv parsing, its scan-scope
602
+ * assertion and its exit codes included. The vitest suite covers the pure
603
+ * classifier; this covers the binary that ships.
604
+ *
605
+ * The floors are set just under the table's real size, so deleting a case to
606
+ * reach green fails instead, and any deliberate shrink shows up as a floor
607
+ * edit a reviewer reads in the diff.
608
+ */
609
+ export function selfTest(): number {
610
+ const repoRoot = path.resolve(path.dirname(_HERE), '..', '..');
611
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lcc-selftest-'));
612
+ const exercise = (testCase: CommentCase, index: number): number => {
613
+ const file = path.join(dir, `case-${String(index)}.ts`);
614
+ fs.writeFileSync(file, testCase.source, 'utf-8');
615
+ return runGateCli(repoRoot, 'src/scripts/lint_code_comments.ts', ['--paths', file], dir);
616
+ };
617
+ try {
618
+ return runSelfTest({
619
+ gate: 'lint_code_comments',
620
+ minCases: 16,
621
+ minRejectCases: 8,
622
+ cases: COMMENT_CASES.map((testCase, index) => ({
623
+ name: testCase.name,
624
+ expect: testCase.flags.length > 0 ? ('reject' as const) : ('accept' as const),
625
+ run: () => exercise(testCase, index),
626
+ })),
627
+ });
628
+ } finally {
629
+ fs.rmSync(dir, { recursive: true, force: true });
630
+ }
631
+ }
632
+
633
+ const HELP = `usage: lint_code_comments [--base <ref>] [--root <dir>] [--paths <f> …] [--report]
634
+
635
+ --base <ref> diff against this ref (default: origin/main)
636
+ --root <dir> run against another checkout (default: this repository)
637
+ --paths scan exactly these files, ignoring the diff
638
+ --report print every finding grouped by file and exit 0
639
+ --self-test drive this binary over its own fixture table and exit 0 on pass
640
+
641
+ Classes: de-comment · report-comment · provenance-comment
642
+ Escape: code-comment-allow <class|*> -- <reason> (per line)
643
+ code-comment-allow-file -- <reason> (first 2000 chars)
644
+ `;
645
+
646
+ export function main(argv: string[]): number {
647
+ // `--help` wins, and the recursion guard is not decoration: `runGateCli`
648
+ // sets GATE_SELF_TEST_CHILD precisely so a child cannot re-enter the suite.
649
+ // Latent today because the child's argv is `--paths <file>`, but any later
650
+ // change that forwards the parent's argv would recurse 18-way per level.
651
+ if (argv.includes('--help') || argv.includes('-h')) {
652
+ process.stdout.write(HELP);
653
+ return 0;
654
+ }
655
+ if (argv.includes('--self-test')) {
656
+ if (process.env['GATE_SELF_TEST_CHILD'] === '1') {
657
+ process.stderr.write('lint_code_comments: refusing to nest --self-test inside a self-test child\n');
658
+ return 2;
659
+ }
660
+ return selfTest();
661
+ }
662
+ let base = 'origin/main';
663
+ let root = path.resolve(path.dirname(_HERE), '..', '..');
664
+ let reportOnly = false;
665
+ const explicit: string[] = [];
666
+
667
+ for (let i = 0; i < argv.length; i++) {
668
+ const a = argv[i];
669
+ if (a === '--help' || a === '-h') { process.stdout.write(HELP); return 0; }
670
+ else if (a === '--base') { base = argv[++i] ?? ''; }
671
+ else if (a === '--root') { root = path.resolve(argv[++i] ?? '.'); }
672
+ else if (a === '--report') { reportOnly = true; }
673
+ else if (a === '--paths') { while (i + 1 < argv.length && !argv[i + 1]!.startsWith('--')) explicit.push(argv[++i]!); }
674
+ else { process.stderr.write(`lint_code_comments: unknown argument ${a}\n${HELP}`); return 1; }
675
+ }
676
+ if (!base && explicit.length === 0) { process.stderr.write('--base needs a ref\n'); return 1; }
677
+
678
+ const candidates = (explicit.length > 0 ? explicit : changedFiles(root, base)).filter(isScannable);
679
+ // Whole file when a caller named the path; only the lines the diff added
680
+ // when the scope IS the diff — see `addedLineMap` for the measured reason.
681
+ const added = explicit.length > 0 ? null : addedLineMap(root, base);
682
+ const findings: CommentFinding[] = [];
683
+ for (const rel of candidates) {
684
+ const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
685
+ let text: string;
686
+ try { text = fs.readFileSync(abs, 'utf-8'); } catch { continue; }
687
+ const all = scanText(rel, text);
688
+ findings.push(...(added ? findingsOnAddedLines(all, added.get(rel)) : all));
689
+ }
690
+
691
+ try {
692
+ reportScanned({
693
+ gate: 'lint_code_comments',
694
+ scanned: candidates.length,
695
+ units: 'source file(s)',
696
+ roots: explicit.length > 0 ? explicit.slice(0, 4) : [`git diff ${base}...HEAD`],
697
+ allowEmpty:
698
+ 'EMPTY_VALID: the scan root is a DIFF, and a diff with no source file in it is the ' +
699
+ 'normal case for a docs-only or config-only change. git already answered for the ' +
700
+ 'range: an unreadable ref yields no candidates AND no findings, which is ' +
701
+ 'indistinguishable here from a clean docs commit, so this gate deliberately does ' +
702
+ 'not treat zero as a dead scope. The scope failure this class exists to catch — a ' +
703
+ 'moved source tree — cannot occur, because there is no committed path list to go ' +
704
+ 'stale.',
705
+ });
706
+ } catch (e) {
707
+ if (e instanceof DeadScopeError) {
708
+ process.stderr.write(`❌ ${e.message}\n`);
709
+ return 1;
710
+ }
711
+ throw e;
712
+ }
713
+ process.stdout.write(` against ${explicit.length > 0 ? 'an explicit path list' : base}\n`);
714
+ if (findings.length === 0) {
715
+ process.stdout.write('✅ lint_code_comments: no German, report-shaped or provenance comments.\n');
716
+ return 0;
717
+ }
718
+
719
+ const byFile = new Map<string, CommentFinding[]>();
720
+ for (const f of findings) { const l = byFile.get(f.file) ?? []; l.push(f); byFile.set(f.file, l); }
721
+ for (const [file, list] of [...byFile].sort()) {
722
+ process.stdout.write(`\n${file} (${list.length})\n`);
723
+ for (const f of list.slice(0, 8)) {
724
+ process.stdout.write(` ${String(f.line).padStart(5)} ${f.cls}/${f.signal} ${f.snippet}\n`);
725
+ }
726
+ if (list.length > 8) process.stdout.write(` … ${list.length - 8} more in this file\n`);
727
+ }
728
+ const counts = new Map<string, number>();
729
+ for (const f of findings) counts.set(f.cls, (counts.get(f.cls) ?? 0) + 1);
730
+ process.stdout.write(`\n${[...counts].map(([c, n]) => `${c}: ${n}`).join(' · ')} across ${byFile.size} file(s)\n`);
731
+
732
+ if (reportOnly) return 0;
733
+ process.stderr.write(
734
+ '\n❌ lint_code_comments: comments carry language, evidence or report structure that does not belong in source.\n' +
735
+ ' A comment states a WHY or a constraint the code cannot show, in English. Evidence belongs in the\n' +
736
+ ' roadmap that asked for it. Rewrite the comment, delete it, or escape one line with\n' +
737
+ ' `code-comment-allow <class> -- <reason>`.\n',
738
+ );
739
+ return 2;
740
+ }
741
+
742
+ const _isMain = (() => {
743
+ const entry = process.argv[1];
744
+ if (!entry) return false;
745
+ try { return fs.realpathSync(entry) === fs.realpathSync(_HERE); } catch { return false; }
746
+ })();
747
+
748
+ if (_isMain) process.exit(main(process.argv.slice(2)));