session-orchestrator 3.20.0 → 3.22.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 (202) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,825 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * auq-audit.mjs — die CLI der AUQ-Klarheitsmessung (#1107).
4
+ *
5
+ * ## Warum es dieses Programm gibt
6
+ *
7
+ * Der Operator hat gesagt, die Fragen, die dieses System ihm stellt, seien zu
8
+ * technisch, zu lang und nicht entscheidbar. Ohne Zahl ist jede Verbesserung
9
+ * daran eine Behauptung. Dieses Programm liefert die Zahl.
10
+ *
11
+ * Es MISST NICHTS SELBST. Es verkabelt drei fertige Module und fügt deren
12
+ * Ergebnis zu einem Bericht zusammen:
13
+ *
14
+ * scripts/lib/auq/parse.mjs findet die Vorlagen (parseRepo)
15
+ * scripts/lib/auq/clarity.mjs bewertet sie (scoreBlocks)
16
+ * scripts/lib/auq/schema.mjs der eingefrorene Vertrag + validateReport
17
+ *
18
+ * Eine Schwelle, ein Kriterium oder eine Note in DIESER Datei wäre ein zweiter
19
+ * Ort für dieselbe Regel. Was hier steht, ist ausschließlich: Argumente,
20
+ * Auswahl, Darstellung, Ausgang.
21
+ *
22
+ * ## Namensabweichung, bewusst dokumentiert
23
+ *
24
+ * Der Modulkopf von `schema.mjs` nennt die CLI `scripts/auq-clarity.mjs`. Der
25
+ * Dateiname ist `auq-audit.mjs` — so lautet der vergebene Datei-Scope. Der
26
+ * VERTRAG (`buildReport({ blocks, scores, corpus, head, dirty }) -> AuqReport`)
27
+ * ist unverändert eingehalten; nur der Dateiname weicht ab.
28
+ *
29
+ * ## Zwei Fallen dieses Repos, gegen die hier gebaut ist
30
+ *
31
+ * 1. **`console.log` + `process.exit()` verwirft stdout oberhalb von ~64 KiB.**
32
+ * stdout ist auf einer Pipe unter macOS asynchron; was nicht mehr in den
33
+ * Kernel-Puffer passt, liegt in der libuv-Warteschlange und wird von
34
+ * `process.exit()` weggeworfen. Der `--json`-Umschlag dieses Programms ist
35
+ * über 1 MB groß und reißt diese Grenze bei JEDEM Lauf. Deshalb geht JEDE
36
+ * stdout-Ausgabe durch `writeStdoutLineSync` (`scripts/lib/io.mjs`), das
37
+ * synchron schreibt und EAGAIN wiederholt.
38
+ * 2. **`no-console` steht in `eslint.config.js` auf `off`.** Der Linter fängt
39
+ * eine nach stdout gerutschte Diagnose NICHT. Die Trennung ist deshalb
40
+ * strukturell: `out()` schreibt nach stdout, `note()` nach stderr, und es
41
+ * gibt keinen dritten Weg.
42
+ *
43
+ * ## Warum `--strict` standardmäßig AUS ist
44
+ *
45
+ * Nur H1 und H2 haben eine gemessene Falsch-Positiv-Rate von 0 %; alle anderen
46
+ * Kriterien liegen zwischen 14 % und 25 %. Ein Prüfer, der darauf hart sperrt,
47
+ * meckert bei jeder vierten korrekten Frage — und wird abgeschaltet. Dann ist
48
+ * auch jede richtig gefundene Schwäche weg. Ohne `--strict` ist ein Lauf mit
49
+ * hunderten Befunden deshalb immer noch exit 0.
50
+ *
51
+ * @see .claude/rules/ask-via-tool.md — AUQ-001..005, die gemessene Regel
52
+ * @see .claude/rules/cli-design.md — JSON-first, stdout/stderr, Exit-Codes
53
+ * @see scripts/lib/tests-src-ratio.mjs — Vorbild für Umschlag, `head` und `dirty`
54
+ * @see Issue #1107
55
+ */
56
+
57
+ import { execFileSync } from 'node:child_process';
58
+ import { existsSync, readFileSync, statSync } from 'node:fs';
59
+ import path from 'node:path';
60
+ import { fileURLToPath } from 'node:url';
61
+ import { parseArgs as nodeParseArgs } from 'node:util';
62
+
63
+ import { scoreBlocks } from './lib/auq/clarity.mjs';
64
+ import {
65
+ CRITERIA,
66
+ CRITERION_IDS,
67
+ GRADES,
68
+ HURDLES,
69
+ HURDLE_IDS,
70
+ POPULATIONS,
71
+ SCHEMA_VERSION,
72
+ emptyCorpus,
73
+ emptySummary,
74
+ truncateExcerpt,
75
+ validateReport,
76
+ } from './lib/auq/schema.mjs';
77
+ import { corpusKindOf, parseRepo } from './lib/auq/parse.mjs';
78
+ import { writeStdoutLineSync } from './lib/io.mjs';
79
+
80
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
81
+ const REPO_ROOT_DEFAULT = path.resolve(HERE, '..');
82
+
83
+ /** Wie viele der schlechtesten Fragen der Bericht ohne `--top` zeigt. */
84
+ const DEFAULT_TOP = 10;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Exit-Codes
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * `.claude/rules/cli-design.md` vergibt 0/1/2. Der vierte Code ist die
92
+ * Gate-Entscheidung und steht bewusst NEBEN dem Eingabefehler: eine gerissene
93
+ * Hürde ist kein Bedienfehler, und wer dieses Programm in CI hängt, muss die
94
+ * beiden auseinanderhalten können. In `--help` dokumentiert (cli-design.md:
95
+ * "Document non-standard exit codes in --help").
96
+ */
97
+ export const EXIT = Object.freeze({
98
+ ok: 0,
99
+ /** Eingabefehler: unbekannte Flagge, unlesbarer Pfad, kaputter Wert. */
100
+ usage: 1,
101
+ /** Systemfehler: der eigene Bericht verletzt sein eigenes Schema. */
102
+ system: 2,
103
+ /** Nur mit `--strict`: mindestens eine harte Grenze ist gerissen. */
104
+ gate: 3,
105
+ });
106
+
107
+ /**
108
+ * Die gesamte Exit-Politik als reine Funktion.
109
+ *
110
+ * Sie steht hier getrennt, weil genau hier der teure Fehler sitzt: ein
111
+ * `validateReport`, dessen Verdikt gelesen und dann ignoriert wird, und ein
112
+ * `--strict`, das verkabelt aussieht und nichts tut. Beides ist an einer
113
+ * reinen Funktion prüfbar, an einem `process.exit()` mitten in `main()` nicht.
114
+ *
115
+ * @param {{reportValid: boolean, strict: boolean, hurdlesBroken: number}} rec
116
+ * @returns {number}
117
+ */
118
+ export function decideExit({ reportValid, strict, hurdlesBroken }) {
119
+ if (!reportValid) return EXIT.system;
120
+ if (strict && hurdlesBroken > 0) return EXIT.gate;
121
+ return EXIT.ok;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Ausgabekanäle — die Trennung, die der Linter hier nicht erzwingt
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Nach stdout. IMMER synchron: der `--json`-Umschlag liegt weit über dem
130
+ * 64-KiB-Pipe-Puffer, und `console.log` + `process.exit()` würde den Rest
131
+ * verwerfen (siehe Modulkopf, Falle 1).
132
+ *
133
+ * @param {string} line
134
+ */
135
+ function out(line) {
136
+ const res = writeStdoutLineSync(line);
137
+ if (!res.ok && res.error !== 'EPIPE') {
138
+ // stdout ist der einzige Kanal, den wir gerade verloren haben — also den
139
+ // anderen nehmen, statt still zu enden.
140
+ process.stderr.write(`auq-audit: stdout-Schreibfehler (${res.error})\n`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Nach stderr. Diagnose, Fortschritt, Fehler — nie nach stdout, sonst ist der
146
+ * `--json`-Umschlag unparsebar.
147
+ *
148
+ * @param {string} line
149
+ */
150
+ function note(line) {
151
+ process.stderr.write(`${line}\n`);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Klartext für den Operator
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Operator-lesbare Überschrift je Kriterium.
160
+ *
161
+ * Warum nicht `CRITERIA[k].title`: das ist der INTERNE Kurzname ("Payload-Form",
162
+ * "Header-Grenze"). Ein Bericht über Verständlichkeit, der mit "K7: 21 Befunde"
163
+ * überschreibt, ist genau die Ironie, an der er scheitert. Die Zuordnung fällt
164
+ * auf `CRITERIA[k].title` zurück, falls die Registry ein Kriterium bekommt, das
165
+ * hier fehlt — dann steht ein Kurzname da, aber keine Lücke.
166
+ */
167
+ const CRITERION_LABELS = Object.freeze({
168
+ K1: 'Frage ist ein Bericht statt einer Frage',
169
+ K2: 'Sehr langer Satz (nur ein Hinweis)',
170
+ K3: 'Beschreibung wiederholt nur das Label',
171
+ K4: 'Empfehlung ohne Grund, Preis oder Folge',
172
+ K5: 'Kopfzeile zu lang — wird abgeschnitten',
173
+ K6: 'Zu lang oder zu viele Optionen',
174
+ K7: 'Fachbegriffe ohne Erklärung',
175
+ K8: 'Verlangt, erst selbst nachzusehen',
176
+ });
177
+
178
+ /** Klartext je Herkunftsklasse. */
179
+ const POPULATION_LABELS = Object.freeze({
180
+ A: 'Claude Code — echter Frage-Aufruf',
181
+ B: 'Codex / Cursor — nummerierte Liste',
182
+ 'C-mdc': 'Cursor-Regeldateien (.mdc)',
183
+ 'C-mjs': 'Skript-Code (.mjs)',
184
+ 'C-prose': 'nur im Fließtext beschrieben',
185
+ 'C-hybrid': 'Mischform aus beidem',
186
+ });
187
+
188
+ /** Klartext je Note. */
189
+ const GRADE_LABELS = Object.freeze({
190
+ A: 'sehr gut',
191
+ B: 'gut',
192
+ C: 'brauchbar',
193
+ D: 'schwach',
194
+ F: 'durchgefallen',
195
+ });
196
+
197
+ /** @param {string} id */
198
+ function criterionLabel(id) {
199
+ return CRITERION_LABELS[id] ?? CRITERIA[id]?.title ?? id;
200
+ }
201
+
202
+ /** @param {string} id */
203
+ function populationLabel(id) {
204
+ return POPULATION_LABELS[id] ?? id;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // git-Anker: WANN wurde gemessen, und ist die Zahl reproduzierbar
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Kurzer HEAD-SHA, oder `null` ausserhalb eines git-Repos.
213
+ *
214
+ * @param {string} root
215
+ * @returns {string|null}
216
+ */
217
+ function headRef(root) {
218
+ try {
219
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
220
+ cwd: root,
221
+ encoding: 'utf8',
222
+ stdio: ['ignore', 'pipe', 'ignore'],
223
+ }).trim();
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Weicht irgendeine GETRACKTE Datei vom Index ab?
231
+ *
232
+ * Tragend, nicht Deko: die Dateiliste kommt aus dem git-INDEX, der Inhalt aber
233
+ * aus dem ARBEITSBAUM. Auf einem schmutzigen Baum ist das Paar (`head`,
234
+ * Notenverteilung) eine Behauptung, die niemand an diesem SHA reproduzieren
235
+ * kann — der Bericht sagt das dann ausdrücklich.
236
+ *
237
+ * FAIL-CLOSED: kann git nicht antworten (kein Repo), gilt `true`. "Unbekannt"
238
+ * ist kein Anker, und `false` würde Reproduzierbarkeit behaupten, die niemand
239
+ * geprüft hat.
240
+ *
241
+ * `--no-optional-locks` verhindert, dass ein beiläufiges `git status` den
242
+ * `.git/index` neu schreibt und damit eine parallele Session anschiebt
243
+ * (PSA-007) — dieselbe Flagge, derselbe Grund wie in tests-src-ratio.mjs.
244
+ *
245
+ * @param {string} root
246
+ * @returns {boolean}
247
+ */
248
+ function isDirty(root) {
249
+ try {
250
+ const outp = execFileSync(
251
+ 'git',
252
+ ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'],
253
+ { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
254
+ );
255
+ return outp.split('\n').some((l) => l.trim() !== '');
256
+ } catch {
257
+ return true;
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Der Umschlag
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Ein Befund zählt gegen die Punkte, wenn er `fail` ist UND nicht befreit.
267
+ * Dieselbe Bedingung wie in `clarity.pointsFor` — hier nur zum ZÄHLEN, nie zum
268
+ * Neuberechnen einer Note.
269
+ *
270
+ * @param {import('./lib/auq/schema.mjs').AuqFinding} f
271
+ */
272
+ function isCountedFailure(f) {
273
+ return f.severity === 'fail' && f.exempt === null;
274
+ }
275
+
276
+ /**
277
+ * Fügt den Bericht zusammen. MISST NICHTS — `summary` wird ausschließlich aus
278
+ * den übergebenen `scores` hochgezählt.
279
+ *
280
+ * `summary` ist bewusst ABGELEITET und kein Parameter: eine von Hand
281
+ * mitgeführte Zusammenfassung ist ein Defekt in Wartestellung.
282
+ *
283
+ * @param {{blocks: import('./lib/auq/schema.mjs').AuqBlock[],
284
+ * scores: import('./lib/auq/schema.mjs').AuqScore[],
285
+ * corpus: Record<string, number>,
286
+ * head: string|null,
287
+ * dirty: boolean,
288
+ * warnings?: string[],
289
+ * filters?: object,
290
+ * measuredAt?: string}} rec
291
+ * @returns {import('./lib/auq/schema.mjs').AuqReport}
292
+ */
293
+ export function buildReport(rec) {
294
+ const blocks = Array.isArray(rec?.blocks) ? rec.blocks : [];
295
+ const scores = Array.isArray(rec?.scores) ? rec.scores : [];
296
+ const summary = emptySummary();
297
+
298
+ for (const score of scores) {
299
+ if (Object.prototype.hasOwnProperty.call(summary.grades, score.grade)) {
300
+ summary.grades[score.grade] += 1;
301
+ }
302
+ for (const finding of score.findings) {
303
+ const bucket = summary.byCriterion[finding.criterion];
304
+ if (bucket !== undefined) bucket[finding.severity] += 1;
305
+ }
306
+ }
307
+
308
+ return {
309
+ schemaVersion: SCHEMA_VERSION,
310
+ measuredAt: typeof rec?.measuredAt === 'string' ? rec.measuredAt : new Date().toISOString(),
311
+ head: typeof rec?.head === 'string' ? rec.head : null,
312
+ dirty: rec?.dirty !== false,
313
+ corpus: { ...emptyCorpus(), ...(rec?.corpus ?? {}) },
314
+ blocks,
315
+ scores,
316
+ summary,
317
+ // Zwei additive Felder ausserhalb des AuqReport-Kerns. `validateReport`
318
+ // ignoriert unbekannte Schlüssel, der Vertrag bleibt also eingehalten.
319
+ // Beide verhindern eine FALSCHE LESART, nicht bloß Bequemlichkeit:
320
+ // warnings — die Extraktion hat etwas übersprungen; ohne diese Liste sähe
321
+ // ein Nicht-Befund wie ein Bestehen aus.
322
+ // filters — eine gefilterte Zahl ist NICHT die Baseline. Wer sie später
323
+ // zitiert, muss sehen können, worüber gemessen wurde.
324
+ warnings: Array.isArray(rec?.warnings) ? [...rec.warnings] : [],
325
+ filters: rec?.filters ?? null,
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Auswahl
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Wendet `--population` / `--file` / `--criterion` auf die BLÖCKE an.
335
+ *
336
+ * Alle drei sind Selektoren auf FRAGEN, nie auf Befunde: würde `--criterion`
337
+ * die Befundliste einer Frage beschneiden, käme eine Note heraus, die es nicht
338
+ * gibt. Eine Frage wird deshalb entweder ganz gezeigt oder gar nicht.
339
+ *
340
+ * @param {import('./lib/auq/schema.mjs').AuqBlock[]} blocks
341
+ * @param {{populations: string[], files: string[], criteria: string[]}} filters
342
+ * @returns {import('./lib/auq/schema.mjs').AuqBlock[]}
343
+ */
344
+ function applyFilters(blocks, filters) {
345
+ const { populations, files, criteria } = filters;
346
+ if (populations.length === 0 && files.length === 0 && criteria.length === 0) return blocks;
347
+
348
+ const matchesFile = (file) =>
349
+ files.length === 0 || files.some((f) => file === f || file.startsWith(`${f.replace(/\/$/u, '')}/`));
350
+
351
+ const out2 = [];
352
+ for (const block of blocks) {
353
+ const kept = block.questions.filter((q) => {
354
+ if (populations.length > 0 && !populations.includes(q.population)) return false;
355
+ if (!matchesFile(q.file)) return false;
356
+ if (criteria.length > 0) {
357
+ // Eine Frage bleibt, wenn sie MINDESTENS EINEN Befund des gesuchten
358
+ // Kriteriums trägt — bewertet wird sie danach unverändert.
359
+ const score = scoreBlocks([{ ...block, questions: [q] }])[0];
360
+ if (!score || !score.findings.some((f) => criteria.includes(f.criterion))) return false;
361
+ }
362
+ return true;
363
+ });
364
+ if (kept.length > 0) out2.push({ ...block, questions: kept });
365
+ }
366
+ return out2;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Kennzahlen für den lesbaren Bericht
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Bestanden heißt: KEINE harte Grenze gerissen UND kein Fehler-Befund.
375
+ *
376
+ * Diese Definition erfindet keine neue Schwelle — sie liest nur zusammen, was
377
+ * `clarity.mjs` bereits entschieden hat. Der Bericht schreibt sie ausdrücklich
378
+ * hin, damit die Leitzahl nicht unbeschriftet dasteht.
379
+ *
380
+ * @param {import('./lib/auq/schema.mjs').AuqScore} score
381
+ */
382
+ function passes(score) {
383
+ return score.hurdlesBroken.length === 0 && !score.findings.some(isCountedFailure);
384
+ }
385
+
386
+ /** @param {number} part @param {number} whole */
387
+ function pct(part, whole) {
388
+ if (whole === 0) return ' 0 %';
389
+ return `${String(Math.round((part / whole) * 100)).padStart(3, ' ')} %`;
390
+ }
391
+
392
+ /** Rechtsbündige Zahl in fester Breite. */
393
+ function num(n, width) {
394
+ return String(n).padStart(width, ' ');
395
+ }
396
+
397
+ /** Linksbündiger Text in fester Breite. */
398
+ function pad(s, width) {
399
+ const chars = [...String(s)];
400
+ return chars.length >= width ? String(s) : String(s) + ' '.repeat(width - chars.length);
401
+ }
402
+
403
+ /**
404
+ * Rangfolge der schlechtesten Fragen: erst die schlechteste Note, dann die
405
+ * wenigsten Punkte, dann die meisten Fehler, zuletzt Datei/Zeile — damit zwei
406
+ * Läufe über denselben Baum dieselbe Reihenfolge liefern und sich diffen lassen.
407
+ *
408
+ * Die Note steht VOR den Punkten, weil eine gerissene harte Grenze die Note
409
+ * übersteuert: eine F-Frage mit 50 Punkten ist nach dem Urteil des Bewerters
410
+ * schlechter als eine D-Frage mit denselben 50 Punkten, und eine Liste, die das
411
+ * umdreht, widerspricht der Tabelle darüber.
412
+ *
413
+ * @param {import('./lib/auq/schema.mjs').AuqScore[]} scores
414
+ */
415
+ function worstFirst(scores) {
416
+ const rank = (g) => GRADES.indexOf(g);
417
+ return [...scores].sort((a, b) => {
418
+ if (a.grade !== b.grade) return rank(b.grade) - rank(a.grade);
419
+ if (a.points !== b.points) return a.points - b.points;
420
+ const af = a.findings.filter(isCountedFailure).length;
421
+ const bf = b.findings.filter(isCountedFailure).length;
422
+ if (af !== bf) return bf - af;
423
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
424
+ return a.line - b.line;
425
+ });
426
+ }
427
+
428
+ /** Findet die Frage zu einem Ergebnis — für Zitat und Herkunft. */
429
+ function questionOf(blocks, score) {
430
+ for (const block of blocks) {
431
+ for (let i = 0; i < block.questions.length; i++) {
432
+ const q = block.questions[i];
433
+ if (q.file === score.file && q.line === score.line && i === score.questionIndex) return q;
434
+ }
435
+ }
436
+ return null;
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Der lesbare Bericht
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Baut den menschenlesbaren Bericht.
445
+ *
446
+ * Dieser Text ist das erste Erzeugnis dieser Messung, das der Operator liest,
447
+ * und er handelt davon, dass Ausgaben lesbar sein sollen. Deshalb: deutsch,
448
+ * keine Kriteriums-Kürzel als Überschrift, keine nackte unbeschriftete Zahl,
449
+ * keine Bedeutung allein über Farbe oder Zeichen, jede Tabelle mit Kopf — und
450
+ * kurz, weil ein Bericht, der nicht zu Ende gelesen wird, nichts misst.
451
+ *
452
+ * @param {import('./lib/auq/schema.mjs').AuqReport} report
453
+ * @param {{top: number}} opts
454
+ * @returns {string}
455
+ */
456
+ export function renderHuman(report, opts) {
457
+ const L = [];
458
+ const scores = report.scores;
459
+ const total = scores.length;
460
+ const passed = scores.filter(passes).length;
461
+ const failed = total - passed;
462
+
463
+ L.push('AUQ-Klarheitsmessung — wie verständlich sind die Fragen, die dieses System stellt?');
464
+ const anchor = report.head ? `HEAD ${report.head}` : 'kein git-Anker';
465
+ L.push(
466
+ `Gemessen am ${report.measuredAt} · ${anchor} · Arbeitsbaum: ${report.dirty ? 'GEÄNDERT' : 'sauber'}`,
467
+ );
468
+ if (report.dirty) {
469
+ L.push(
470
+ ' Achtung: der Arbeitsbaum weicht vom letzten Commit ab. Diese Zahlen sind an diesem',
471
+ ' Stand NICHT reproduzierbar — für eine zitierfähige Baseline auf sauberem Baum messen.',
472
+ );
473
+ }
474
+ if (report.filters) {
475
+ const f = report.filters;
476
+ const parts = [];
477
+ if (f.populations?.length) parts.push(`Herkunft: ${f.populations.join(', ')}`);
478
+ if (f.files?.length) parts.push(`Dateien: ${f.files.join(', ')}`);
479
+ if (f.criteria?.length) parts.push(`Kriterien: ${f.criteria.join(', ')}`);
480
+ if (parts.length > 0) {
481
+ L.push(` Eingegrenzt auf ${parts.join(' · ')} — das ist NICHT die Gesamtzahl des Repos.`);
482
+ }
483
+ }
484
+ L.push('');
485
+
486
+ // --- Die Leitzahl -------------------------------------------------------
487
+ L.push(
488
+ `${passed} von ${total} Fragen bestehen (${pct(passed, total).trim()}). ` +
489
+ `${failed} bestehen nicht (${pct(failed, total).trim()}).`,
490
+ );
491
+ L.push(
492
+ ' Bestanden heißt: keine harte Grenze gerissen und kein einziger Fehler-Befund.',
493
+ );
494
+ L.push('');
495
+
496
+ if (total === 0) {
497
+ L.push('Keine Frage-Vorlage gefunden. Prüfe die Eingrenzung oder den Pfad.');
498
+ return L.join('\n');
499
+ }
500
+
501
+ // --- Noten --------------------------------------------------------------
502
+ L.push('Noten (Punkte von 100; eine gerissene harte Grenze ergibt immer die schlechteste Note)');
503
+ L.push(` ${pad('Note', 22)}${num('Fragen', 7)} Anteil`);
504
+ for (const g of GRADES) {
505
+ const n = report.summary.grades[g] ?? 0;
506
+ L.push(` ${pad(`${g} — ${GRADE_LABELS[g] ?? ''}`, 22)}${num(n, 7)} ${pct(n, total)}`);
507
+ }
508
+ L.push('');
509
+
510
+ // --- Harte Grenzen ------------------------------------------------------
511
+ const brokenBy = {};
512
+ for (const h of HURDLE_IDS) brokenBy[h] = 0;
513
+ let anyHurdle = 0;
514
+ for (const s of scores) {
515
+ if (s.hurdlesBroken.length > 0) anyHurdle += 1;
516
+ for (const h of s.hurdlesBroken) brokenBy[h] = (brokenBy[h] ?? 0) + 1;
517
+ }
518
+ L.push('Harte Grenzen — das Werkzeug selbst schneidet hier ab, das ist keine Stilfrage');
519
+ L.push(` ${pad("Grenze", 56)}${num('Fragen', 7)} Anteil`);
520
+ for (const h of HURDLE_IDS) {
521
+ L.push(` ${pad(HURDLES[h].title, 56)}${num(brokenBy[h], 7)} ${pct(brokenBy[h], total)}`);
522
+ }
523
+ L.push(` ${pad("mindestens eine der beiden gerissen", 56)}${num(anyHurdle, 7)} ${pct(anyHurdle, total)}`);
524
+ L.push('');
525
+
526
+ // --- Befunde je Kriterium -----------------------------------------------
527
+ L.push('Was schiefgeht — Fehler ziehen Punkte ab, Hinweise nicht');
528
+ L.push(` ${pad('Befund', 46)}${num('Fehler', 8)}${num('Hinweise', 10)}`);
529
+ const rows = CRITERION_IDS.map((k) => ({ k, ...report.summary.byCriterion[k] })).sort(
530
+ (a, b) => b.fail - a.fail || b.warn - a.warn,
531
+ );
532
+ for (const r of rows) {
533
+ if (r.fail === 0 && r.warn === 0) continue;
534
+ L.push(` ${pad(criterionLabel(r.k), 46)}${num(r.fail, 8)}${num(r.warn, 10)}`);
535
+ }
536
+ L.push('');
537
+
538
+ // --- Nach Herkunft ------------------------------------------------------
539
+ const byPop = new Map();
540
+ for (const s of scores) {
541
+ const q = questionOf(report.blocks, s);
542
+ const p = q?.population ?? 'unbekannt';
543
+ const rec = byPop.get(p) ?? { total: 0, passed: 0, hurdles: 0 };
544
+ rec.total += 1;
545
+ if (passes(s)) rec.passed += 1;
546
+ if (s.hurdlesBroken.length > 0) rec.hurdles += 1;
547
+ byPop.set(p, rec);
548
+ }
549
+ L.push('Nach Herkunft — wo die Frage steht, entscheidet, welches Werkzeug sie stellt');
550
+ L.push(` ${pad('Herkunft', 38)}${num('Fragen', 7)}${num('bestehen', 10)} Anteil`);
551
+ for (const p of POPULATIONS) {
552
+ const rec = byPop.get(p);
553
+ if (!rec) continue;
554
+ L.push(
555
+ ` ${pad(`${p} — ${populationLabel(p)}`, 38)}${num(rec.total, 7)}${num(rec.passed, 10)} ` +
556
+ `${pct(rec.passed, rec.total)}`,
557
+ );
558
+ }
559
+ L.push('');
560
+
561
+ // --- Die schlechtesten Fragen -------------------------------------------
562
+ const worst = worstFirst(scores).slice(0, Math.max(0, opts.top));
563
+ if (worst.length > 0) {
564
+ L.push(`Die ${worst.length} schlechtesten Fragen — Datei und Zeile zum Nachschlagen`);
565
+ worst.forEach((s, i) => {
566
+ const q = questionOf(report.blocks, s);
567
+ const hurdleNote =
568
+ s.hurdlesBroken.length > 0 ? ` · harte Grenze gerissen: ${s.hurdlesBroken.join(', ')}` : '';
569
+ L.push(` ${num(i + 1, 2)}. ${s.file}:${s.line} — Note ${s.grade}, ${s.points} Punkte${hurdleNote}`);
570
+ if (q) L.push(` Frage: „${truncateExcerpt(q.question)}"`);
571
+ const reasons = [...new Set(s.findings.filter(isCountedFailure).map((f) => criterionLabel(f.criterion)))];
572
+ if (reasons.length > 0) L.push(` Fehler: ${reasons.join(' · ')}`);
573
+ });
574
+ L.push('');
575
+ }
576
+
577
+ // --- Leseprobleme -------------------------------------------------------
578
+ if (report.warnings.length > 0) {
579
+ L.push(`Beim Einlesen übersprungen oder auffällig (${report.warnings.length})`);
580
+ for (const w of report.warnings.slice(0, 10)) L.push(` - ${w}`);
581
+ if (report.warnings.length > 10) L.push(` … und ${report.warnings.length - 10} weitere`);
582
+ L.push('');
583
+ }
584
+
585
+ L.push('Vollständige Daten je Frage und Befund: dasselbe Kommando mit --json.');
586
+ return L.join('\n');
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Argumente
591
+ // ---------------------------------------------------------------------------
592
+
593
+ const USAGE = 'Aufruf: node scripts/auq-audit.mjs [<repo-wurzel>] [--json] [--strict] [--top N] [--criterion K7] [--population A] [--file <pfad>]';
594
+
595
+ /**
596
+ * @param {string[]} argv
597
+ * @returns {{error: string|null, help: boolean, version: boolean, json: boolean,
598
+ * strict: boolean, top: number, criteria: string[], populations: string[],
599
+ * files: string[], root: string}}
600
+ */
601
+ export function parseCliArgs(argv) {
602
+ const base = {
603
+ error: null,
604
+ help: false,
605
+ version: false,
606
+ json: false,
607
+ strict: false,
608
+ top: DEFAULT_TOP,
609
+ criteria: [],
610
+ populations: [],
611
+ files: [],
612
+ root: REPO_ROOT_DEFAULT,
613
+ };
614
+
615
+ let parsed;
616
+ try {
617
+ parsed = nodeParseArgs({
618
+ args: argv,
619
+ allowPositionals: true,
620
+ strict: true,
621
+ options: {
622
+ json: { type: 'boolean', default: false },
623
+ strict: { type: 'boolean', default: false },
624
+ help: { type: 'boolean', default: false },
625
+ version: { type: 'boolean', default: false },
626
+ top: { type: 'string' },
627
+ criterion: { type: 'string', multiple: true },
628
+ population: { type: 'string', multiple: true },
629
+ file: { type: 'string', multiple: true },
630
+ },
631
+ });
632
+ } catch (err) {
633
+ return { ...base, error: err?.message ?? String(err) };
634
+ }
635
+
636
+ const v = parsed.values;
637
+ const criteria = v.criterion ?? [];
638
+ for (const c of criteria) {
639
+ if (!CRITERION_IDS.includes(c)) {
640
+ return { ...base, error: `unbekanntes Kriterium: ${c} (erlaubt: ${CRITERION_IDS.join(', ')})` };
641
+ }
642
+ }
643
+ const populations = v.population ?? [];
644
+ for (const p of populations) {
645
+ if (!POPULATIONS.includes(p)) {
646
+ return { ...base, error: `unbekannte Herkunft: ${p} (erlaubt: ${POPULATIONS.join(', ')})` };
647
+ }
648
+ }
649
+
650
+ let top = DEFAULT_TOP;
651
+ if (v.top !== undefined) {
652
+ const n = Number(v.top);
653
+ if (!Number.isInteger(n) || n < 0) {
654
+ return { ...base, error: `--top braucht eine ganze Zahl >= 0 (bekommen: ${v.top})` };
655
+ }
656
+ top = n;
657
+ }
658
+
659
+ if (parsed.positionals.length > 1) {
660
+ return { ...base, error: `höchstens eine Repo-Wurzel erlaubt (bekommen: ${parsed.positionals.length})` };
661
+ }
662
+
663
+ return {
664
+ ...base,
665
+ help: v.help === true,
666
+ version: v.version === true,
667
+ json: v.json === true,
668
+ strict: v.strict === true,
669
+ top,
670
+ criteria: [...criteria],
671
+ populations: [...populations],
672
+ files: (v.file ?? []).map((f) => f.replace(/\\/gu, '/').replace(/^\.\//u, '')),
673
+ root: parsed.positionals[0] ? path.resolve(parsed.positionals[0]) : REPO_ROOT_DEFAULT,
674
+ };
675
+ }
676
+
677
+ function printHelp() {
678
+ out(USAGE);
679
+ out('');
680
+ out('Misst, wie verständlich die Fragen sind, die dieses System dem Operator stellt.');
681
+ out('Findet jede Frage-Vorlage im Repo, benotet sie gegen acht Kriterien und zwei');
682
+ out('harte Grenzen, und gibt eine Notenverteilung aus. Rein deterministisch, kein LLM.');
683
+ out('');
684
+ out(' --json maschinenlesbarer Umschlag auf stdout, sonst nichts');
685
+ out(' --strict Ausgang 3, wenn mindestens eine harte Grenze gerissen ist');
686
+ out(` --top N die N schlechtesten Fragen zeigen (Vorgabe: ${DEFAULT_TOP})`);
687
+ out(` --criterion K7 nur Fragen mit einem Befund dieses Kriteriums (erlaubt: ${CRITERION_IDS.join(', ')})`);
688
+ out(` --population A nur diese Herkunft (erlaubt: ${POPULATIONS.join(', ')})`);
689
+ out(' --file <pfad> nur diese Datei oder dieses Verzeichnis (repo-relativ)');
690
+ out(' --help / --version');
691
+ out('');
692
+ out(' <repo-wurzel> Wurzel des zu messenden Repos (Vorgabe: dieses Repo)');
693
+ out('');
694
+ out('Alle drei Eingrenzungen wirken auf FRAGEN, nie auf Befunde: eine Frage wird ganz');
695
+ out('gezeigt oder gar nicht. Eine eingegrenzte Zahl ist nie die Gesamtzahl des Repos —');
696
+ out('der Umschlag führt die gesetzten Eingrenzungen deshalb im Feld "filters" mit.');
697
+ out('');
698
+ out('Warum --strict standardmäßig aus ist: nur die beiden harten Grenzen haben eine');
699
+ out('gemessene Falsch-Positiv-Rate von 0 %, die übrigen Kriterien liegen zwischen 14 %');
700
+ out('und 25 %. Ein Prüfer, der darauf sperrt, meckert korrekte Fragen an und wird');
701
+ out('abgeschaltet — dann ist auch jeder echte Befund weg.');
702
+ out('');
703
+ out('Ausgang: 0 Erfolg · 1 Eingabefehler · 2 Systemfehler (Bericht verletzt sein');
704
+ out('eigenes Schema) · 3 nur mit --strict: harte Grenze gerissen.');
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // main
709
+ // ---------------------------------------------------------------------------
710
+
711
+ function main() {
712
+ const args = parseCliArgs(process.argv.slice(2));
713
+
714
+ if (args.error) {
715
+ note(`Fehler: ${args.error}`);
716
+ note(USAGE);
717
+ process.exit(EXIT.usage);
718
+ }
719
+ if (args.help) {
720
+ printHelp();
721
+ process.exit(EXIT.ok);
722
+ }
723
+ if (args.version) {
724
+ // Die Version des Umschlag-Schemas ist die, an der ein Konsument hängt —
725
+ // die Paketversion steht daneben, weil `--version` sie erwartet.
726
+ out(`auq-audit ${pkgVersion()} (Umschlag ${SCHEMA_VERSION})`);
727
+ process.exit(EXIT.ok);
728
+ }
729
+
730
+ if (!existsSync(args.root) || !statSync(args.root).isDirectory()) {
731
+ note(`Fehler: Repo-Wurzel nicht lesbar: ${args.root}`);
732
+ process.exit(EXIT.usage);
733
+ }
734
+
735
+ // Nennt `--file` ausschließlich konkrete Korpus-DATEIEN, werden genau die
736
+ // gelesen und `git ls-files` gar nicht erst gerufen — so ist die CLI auch auf
737
+ // einem Verzeichnis ohne git-Repo benutzbar (und genau so testbar). Sobald
738
+ // ein Verzeichnis-PRÄFIX dabei ist, braucht es die Aufzählung; die
739
+ // Eingrenzung übernimmt dann `applyFilters`.
740
+ const explicit = args.files.filter((f) => corpusKindOf(f) !== null);
741
+ const prefixes = args.files.filter((f) => corpusKindOf(f) === null);
742
+ let files;
743
+ if (args.files.length > 0 && prefixes.length === 0) {
744
+ const missing = explicit.filter((f) => !existsSync(path.join(args.root, f)));
745
+ if (missing.length > 0) {
746
+ note(`Fehler: Datei nicht gefunden: ${missing.join(', ')}`);
747
+ process.exit(EXIT.usage);
748
+ }
749
+ files = explicit;
750
+ }
751
+
752
+ const parsedRepo = parseRepo({ repoRoot: args.root, files });
753
+ const blocks = applyFilters(parsedRepo.blocks, {
754
+ populations: args.populations,
755
+ files: args.files,
756
+ criteria: args.criteria,
757
+ });
758
+ const scores = scoreBlocks(blocks);
759
+
760
+ // `corpus` wird aus den GEFILTERTEN Blöcken neu gezählt, sonst behauptete der
761
+ // Umschlag eine Grundgesamtheit, die im Bericht gar nicht steht.
762
+ const corpus = emptyCorpus();
763
+ for (const b of blocks) {
764
+ const p = b.questions[0]?.population;
765
+ if (p !== undefined) corpus[p] += 1;
766
+ }
767
+
768
+ const hasFilters =
769
+ args.populations.length > 0 || args.files.length > 0 || args.criteria.length > 0;
770
+
771
+ // Ist der Bericht eingegrenzt, müssen die Lesewarnungen mitgehen: eine
772
+ // Warnung über eine Datei, die im Bericht gar nicht vorkommt, liest sich wie
773
+ // ein Mangel der gezeigten Auswahl. Jede Warnung beginnt mit `datei:zeile:`.
774
+ const keptFiles = new Set(blocks.flatMap((b) => b.questions.map((q) => q.file)));
775
+ const warnings = hasFilters
776
+ ? parsedRepo.warnings.filter((w) => [...keptFiles].some((f) => w.startsWith(`${f}:`)))
777
+ : parsedRepo.warnings;
778
+
779
+ const report = buildReport({
780
+ blocks,
781
+ scores,
782
+ corpus,
783
+ head: headRef(args.root),
784
+ dirty: isDirty(args.root),
785
+ warnings,
786
+ filters: hasFilters
787
+ ? { populations: args.populations, files: args.files, criteria: args.criteria }
788
+ : null,
789
+ });
790
+
791
+ // Ein Bericht, der sein eigenes Schema verletzt, ist schlimmer als keiner:
792
+ // er sieht aus wie eine Messung und ist eine. Deshalb VOR der Ausgabe prüfen
793
+ // und im Fehlerfall gar keinen Umschlag schreiben.
794
+ const verdict = validateReport(report);
795
+ if (!verdict.ok) {
796
+ note('Fehler: der erzeugte Bericht verletzt sein eigenes Schema — nichts ausgegeben.');
797
+ for (const e of verdict.errors.slice(0, 20)) note(` - ${e}`);
798
+ if (verdict.errors.length > 20) note(` … und ${verdict.errors.length - 20} weitere`);
799
+ process.exit(decideExit({ reportValid: false, strict: args.strict, hurdlesBroken: 0 }));
800
+ }
801
+
802
+ if (args.json) out(JSON.stringify(report, null, 2));
803
+ else out(renderHuman(report, { top: args.top }));
804
+
805
+ const hurdlesBroken = scores.filter((s) => s.hurdlesBroken.length > 0).length;
806
+ if (args.strict && hurdlesBroken > 0) {
807
+ note(`--strict: ${hurdlesBroken} Frage(n) reißen eine harte Grenze.`);
808
+ }
809
+ process.exit(decideExit({ reportValid: true, strict: args.strict, hurdlesBroken }));
810
+ }
811
+
812
+ /** Paketversion, oder `0.0.0` wenn package.json nicht lesbar ist. */
813
+ function pkgVersion() {
814
+ try {
815
+ return JSON.parse(readFileSync(path.join(REPO_ROOT_DEFAULT, 'package.json'), 'utf8')).version;
816
+ } catch {
817
+ return '0.0.0';
818
+ }
819
+ }
820
+
821
+ const invokedDirectly =
822
+ process.argv[1] !== undefined &&
823
+ path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
824
+
825
+ if (invokedDirectly) main();