session-orchestrator 3.21.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 (117) 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/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,1314 @@
1
+ /**
2
+ * auq/clarity.mjs — der Bewerter der AUQ-Klarheitsmessung (#1107).
3
+ *
4
+ * ## Was dieses Modul tut, und was ausdrücklich nicht
5
+ *
6
+ * Es BEWERTET fertig geparste Fragen und PARST NICHTS. Kein Datei-IO, kein
7
+ * Netz, kein LLM, keine neue Abhängigkeit — reine Funktion von `AuqQuestion`
8
+ * nach `AuqScore`. Die Vorlagen findet `scripts/lib/auq/parse.mjs`, den Bericht
9
+ * baut `scripts/auq-audit.mjs`.
10
+ *
11
+ * JEDE Schwelle, jedes Muster und jede Wortliste kommt aus `./schema.mjs`. Ein
12
+ * Zahlenliteral in dieser Datei wäre ein zweiter Ort für dieselbe Schwelle —
13
+ * und damit ein Defekt in Wartestellung: ändert sich die Registry, driftet der
14
+ * Bewerter still. Die einzigen Zahlen hier sind `MAX_POINTS` (die Skala der
15
+ * Punkteformel, die im Schema-Kopf steht) und die 0 der unteren Klemmung.
16
+ *
17
+ * ## Die Randbedingung, die über allen acht Kriterien steht
18
+ *
19
+ * Ein Prüfer, der korrekte Fragen anmeckert, wird abgeschaltet — und dann ist
20
+ * jede richtig gefundene Schwäche mit ihm weg. Falsch-Positive kosten deshalb
21
+ * mehr als Falsch-Negative. Gemessen haben nur K5 und H2 eine FP-Rate von 0 %;
22
+ * alles andere liegt zwischen 14 % und 25 %. Konsequenzen im Code:
23
+ *
24
+ * - Im Zweifel wird NICHT gemeldet (Platzhalter-Texte werden übersprungen,
25
+ * Aufzählungen zählen bei K1 nicht als Bericht, K3 ist eine Konjunktion).
26
+ * - Hart scheitert nur, was eine Hürde reißt (H1/H2). Die Punktzahl wird
27
+ * berichtet, nicht erzwungen — `--strict` ist in der CLI standardmäßig aus.
28
+ *
29
+ * ## Das Feld, an dem sich dieses Programm selbst misst
30
+ *
31
+ * `AuqFinding.message` steht später vor dem Operator. Deutsch, ohne Fachjargon,
32
+ * und es nennt WAS gemessen wurde — nie die Kriteriums-ID. Eine Meldung wie
33
+ * "K7 violation: unglossed identifier token" wäre genau die Ironie, an der ein
34
+ * Klarheits-Prüfer scheitert.
35
+ *
36
+ * ## Der Schutz, der nicht wegoptimiert werden darf
37
+ *
38
+ * Ein Text, der auf `SAFETY_PATTERN` passt, ist von den LÄNGEN-Kriterien
39
+ * (`LENGTH_CRITERIA` = K1, K6) befreit. Das ist hier STRUKTURELL verdrahtet,
40
+ * nicht per Disziplin: `lengthFinding()` ist der einzige Weg, einen K1/K6-Befund
41
+ * zu bauen, und wirft, wenn man ein anderes Kriterium hindurchschickt;
42
+ * `finding()` wirft umgekehrt für K1/K6. Ein befreiter Befund wird auf `warn`
43
+ * herabgestuft UND trägt `exempt: 'length'` — die Punkterechnung ignoriert
44
+ * beides unabhängig voneinander, damit ein Ausrutscher an einer Stelle nicht
45
+ * reicht, um eine Sicherheitswarnung wegkürzbar zu machen.
46
+ *
47
+ * K3/K4/K5/K7/K8 gelten für Sicherheitstexte WEITER. Sie fügen Information
48
+ * hinzu (Grund, Preis, Erklärung) und nehmen keine weg — eine Warnung wird
49
+ * durch einen genannten Grund besser, nicht kürzer.
50
+ *
51
+ * ## Woher die Zahlen in den Begründungen stammen
52
+ *
53
+ * Jede Quote unten ("44 % Falsch-Positive", "fünf von 120 Paaren") kommt aus der
54
+ * Discovery-Zählung vom 2026-08-22 über 120 Optionen in 33 doppelt gequoteten
55
+ * Fragen. Dieser Zensus wurde danach nach oben korrigiert: 42 Fragen (33 doppelt
56
+ * gequotet + 9 in Backticks) mit 156 Optionen — ein zeilenverankertes grep hatte
57
+ * drei kompakte Einzeiler in skills/plan/SKILL.md verloren.
58
+ *
59
+ * Die NENNER unten sind also die des kleineren Zensus. Sie stehen hier als
60
+ * Begründung dafür, warum ein Kriterium so und nicht anders geschnitten ist,
61
+ * nicht als aktueller Bestand — der lebt im Bericht, den `scripts/auq-audit.mjs`
62
+ * erzeugt, und wird bei jeder Vorlagenänderung neu gemessen. Kein Test pinnt eine
63
+ * dieser Gesamtzahlen; ein Zählstand in einem Unit-Test driftet garantiert.
64
+ *
65
+ * @see ./schema.mjs — der eingefrorene Vertrag (Kriterien, Schwellen, Fabriken)
66
+ * @see .claude/rules/ask-via-tool.md — AUQ-001..005, die gemessene Regel
67
+ */
68
+
69
+ import {
70
+ CRITERIA,
71
+ CRITERION_IDS,
72
+ THRESHOLDS,
73
+ HURDLES,
74
+ LENGTH_CRITERIA,
75
+ CODE_FENCE_PATTERN,
76
+ TABLE_ROW_PATTERN,
77
+ LIST_LINE_PATTERN,
78
+ NEWLINE_PATTERN,
79
+ MARKER_CLASSES,
80
+ MARKER_CLASS_IDS,
81
+ RECOMMENDED_MARKERS,
82
+ STOPWORDS,
83
+ HEADER_PLACEHOLDER_PATTERN,
84
+ IDENTIFIER_PATTERNS,
85
+ IDENTIFIER_ALLOWLIST,
86
+ GLOSS_RULE,
87
+ READ_IMPERATIVE_PATTERN,
88
+ FIRST_PERSON_EXCLUSION_PATTERN,
89
+ isLengthExempt,
90
+ codepointLength,
91
+ normalizeLiteralNewlines,
92
+ globalOf,
93
+ makeFinding,
94
+ makeScore,
95
+ } from './schema.mjs';
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Struktur-Konstanten (KEINE Schwellen — die stehen alle in schema.mjs)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Die Skala der Punkteformel aus dem Schema-Kopf: `points = 100 − Σ weight`.
103
+ * Bewusst NICHT `TOTAL_WEIGHT`: dass die Gewichtssumme heute ebenfalls 100 ist,
104
+ * ist eine Invariante der Registry, nicht die Definition der Skala. Würden die
105
+ * beiden über `TOTAL_WEIGHT` gekoppelt, verschöbe eine Gewichtsänderung
106
+ * stillschweigend auch die Notenbänder.
107
+ */
108
+ const MAX_POINTS = 100;
109
+
110
+ /**
111
+ * Ein Platzhalter im Text: `<name>` oder `{{name}}`. Bewusst zeilenbegrenzt und
112
+ * ohne Längengrenze, damit hier keine Zahl steht.
113
+ *
114
+ * Die eckige Klammer `[...]` aus `HEADER_PLACEHOLDER_PATTERN` ist hier ABSICHTLICH
115
+ * NICHT dabei: in Kopfzeilen ist `[x]` ein Platzhalter, in Fragetexten ist es
116
+ * echter Inhalt (`"- [X] critical"` in skills/discovery/SKILL.md). Sie mit
117
+ * aufzunehmen würde reale Aufzählungen wegfiltern.
118
+ */
119
+ const PLACEHOLDER_PATTERN = /<[^<>\n]+>|\{\{[^{}\n]+\}\}/u;
120
+
121
+ /** Die im Korpus verwendete Platzhalter-Floskel ohne Klammern. */
122
+ const PLACEHOLDER_PHRASE = 'one-line description';
123
+
124
+ /**
125
+ * Satzgrenze: Punkt/Ausrufe-/Fragezeichen, Leerraum, und danach ein
126
+ * GROSSBUCHSTABE oder eine öffnende Klammer.
127
+ *
128
+ * Die Großbuchstaben-Bedingung ist tragend, nicht kosmetisch: ohne sie zerfällt
129
+ * `"… docs/prd/YYYY-MM-DD-<feature>.md — most precise decomposition."` am `.md `
130
+ * in zwei Sätze, und die Glosse hinter dem Gedankenstrich landet in einem
131
+ * anderen Satz als der Bezeichner, den sie erklärt. K7 meldete dann einen
132
+ * Falsch-Positiv auf einer vorbildlich glossierten Option.
133
+ */
134
+ const SENTENCE_BOUNDARY = /(?<=[.!?])\s+(?=["'([{\p{Lu}])/u;
135
+
136
+ /** Wortgrenze für Wortzählungen. */
137
+ const WHITESPACE = /\s+/u;
138
+
139
+ /** Alles, was kein Buchstabe und keine Ziffer ist, trennt Inhaltswörter. */
140
+ const NON_WORD = /[^\p{L}\p{N}]+/u;
141
+
142
+ /** Punkte werden aus dem K8-Abstand herausgerechnet (siehe THRESHOLDS.K8). */
143
+ const PERIOD = /\./u;
144
+
145
+ /**
146
+ * Eine Einsetzung in einem Backtick-Literal: `${…}`.
147
+ *
148
+ * In einem doppelt gequoteten String ist dieselbe Zeichenfolge wörtlicher Text
149
+ * und KEINE Einsetzung — deshalb wird zusätzlich `quoting === 'backtick'`
150
+ * geprüft, statt allein auf dieses Muster zu gehen.
151
+ */
152
+ const INTERPOLATION = /\$\{/u;
153
+
154
+ /**
155
+ * Der Zusatz, der eine Längenangabe als UNTERGRENZE kennzeichnet.
156
+ *
157
+ * Neun Fragen im Korpus stehen in Backticks und setzen Werte ein
158
+ * (`${blockingSession.worktreePath}` wird zu einem absoluten Pfad). Am
159
+ * Quelltext gemessen ist die Zahl also kleiner als das, was der Operator sieht.
160
+ *
161
+ * WICHTIG, in welche Richtung das wirkt: die Unsicherheit ist EINSEITIG. Reißt
162
+ * schon die Untergrenze eine Schwelle, ist der Verstoß sicher — er wird durch
163
+ * die Einsetzung nur größer. Gefährlich ist allein der stumme Gegenfall: eine
164
+ * Frage, die im Quelltext unter der Schwelle liegt und auf dem Schirm darüber.
165
+ * Den kann dieses Modul nicht sehen, und es wird ihn nicht schätzen — ein
166
+ * erfundener Expansionsfaktor wäre eine Zahl ohne Messung. Der Zusatz sorgt
167
+ * dafür, dass ein späterer Leser die neun Werte nicht für exakt hält.
168
+ *
169
+ * @param {import('./schema.mjs').AuqQuestion} q
170
+ * @param {string} text der Text, dessen Länge gemessen wurde
171
+ * @returns {string} leerer String, wenn die Messung exakt ist
172
+ */
173
+ function lowerBoundNote(q, text) {
174
+ if (q.quoting !== 'backtick') return '';
175
+ if (!INTERPOLATION.test(str(text))) return '';
176
+ return (
177
+ ' Die Vorlage setzt zur Laufzeit Werte ein — der gemessene Wert ist eine UNTERGRENZE, ' +
178
+ 'auf dem Schirm steht mehr.'
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Satzzeichen am ENDE eines gefundenen Bezeichners.
184
+ *
185
+ * Das Pfadmuster ist gierig und verschluckt den Satzpunkt: aus
186
+ * `"… via node scripts/telemetry.mjs."` wird das Token `scripts/telemetry.mjs.`.
187
+ * Das ist nicht nur hässlich in der Meldung — die Allowlist vergleicht per
188
+ * VERTRAG auf exakte Zeichengleichheit, also verfehlt `STATE.md.` am Satzende
189
+ * den Eintrag `STATE.md` und K7 meldet einen ausdrücklich erlaubten Bezeichner
190
+ * als unerklärt. Führende Punkte bleiben erhalten (`.orchestrator/session.lock`).
191
+ */
192
+ const TRAILING_PUNCTUATION = /[.,;:!?)\]]+$/u;
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Textprimitive
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** @param {unknown} v @returns {string} */
199
+ function str(v) {
200
+ return typeof v === 'string' ? v : '';
201
+ }
202
+
203
+ /**
204
+ * Entfernt Platzhalter ERSATZLOS (nicht durch ein Leerzeichen).
205
+ *
206
+ * Ersatzlos ist der Punkt: `docs/prd/YYYY-MM-DD-<feature>.md` zerfällt sonst in
207
+ * die zwei Bruchstücke `docs/prd/YYYY-MM-DD-` und `.md`, von denen die Glosse
208
+ * hinter dem Gedankenstrich nur das zweite erreicht. Ein Platzhalter ist ein
209
+ * zur Laufzeit gefülltes Loch, kein Wortende.
210
+ *
211
+ * @param {string} text
212
+ * @returns {string}
213
+ */
214
+ function stripPlaceholders(text) {
215
+ return str(text).replace(globalOf(PLACEHOLDER_PATTERN), '');
216
+ }
217
+
218
+ /**
219
+ * Entfernt `(Recommended)` / `(Empfohlen)` aus einem Label.
220
+ * @param {string} text
221
+ * @returns {string}
222
+ */
223
+ function stripRecommendedMarkers(text) {
224
+ let out = str(text);
225
+ for (const marker of RECOMMENDED_MARKERS) out = out.split(marker).join('');
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Ist der Text NUR ein Platzhalter (und damit nichts, was man bewerten kann)?
231
+ *
232
+ * Eine Vorlage wie `"<one-line description of what this tier scaffolds>"` ist
233
+ * kein Verstoß gegen K3/K4 — sie ist noch gar kein Text. Ohne diese Prüfung
234
+ * meldet der Bewerter genau die Stellen, an denen der Autor bereits weiß, dass
235
+ * dort etwas hingehört: die teuerste Sorte Falsch-Positiv, weil sie den Prüfer
236
+ * unglaubwürdig macht, ohne irgendetwas zu finden.
237
+ *
238
+ * @param {string} text
239
+ * @returns {boolean}
240
+ */
241
+ function isPlaceholderOnly(text) {
242
+ const s = str(text);
243
+ if (s.toLowerCase().includes(PLACEHOLDER_PHRASE)) return true;
244
+ return stripRecommendedMarkers(stripPlaceholders(s)).trim() === '';
245
+ }
246
+
247
+ /**
248
+ * Zerlegt einen Text in Sätze. K7 und K8 messen JE SATZ — eine Erklärung im
249
+ * nächsten Satz ist keine Glosse mehr, und ein Lese-Imperativ im vorigen Satz
250
+ * zeigt nicht auf den Pfad im aktuellen.
251
+ *
252
+ * @param {string} text
253
+ * @returns {string[]}
254
+ */
255
+ function sentencesOf(text) {
256
+ const flat = normalizeLiteralNewlines(str(text)).trim();
257
+ if (flat === '') return [];
258
+ return flat
259
+ .split(SENTENCE_BOUNDARY)
260
+ .map((s) => s.trim())
261
+ .filter((s) => s !== '');
262
+ }
263
+
264
+ /** @param {string} text @returns {string[]} */
265
+ function wordsOf(text) {
266
+ return str(text).trim().split(WHITESPACE).filter((w) => w !== '');
267
+ }
268
+
269
+ /**
270
+ * Inhaltswörter eines Textes: kleingeschrieben, ohne Platzhalter, ohne
271
+ * Empfehlungsmarker, ohne Stoppwörter, ohne zu kurze Token.
272
+ *
273
+ * @param {string} text
274
+ * @returns {Set<string>}
275
+ */
276
+ function contentTokens(text) {
277
+ const out = new Set();
278
+ const cleaned = stripRecommendedMarkers(stripPlaceholders(text)).toLowerCase();
279
+ for (const token of cleaned.split(NON_WORD)) {
280
+ if (token === '') continue;
281
+ if (codepointLength(token) < THRESHOLDS.K3.tokenMinChars) continue;
282
+ if (STOPWORDS.has(token)) continue;
283
+ out.add(token);
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Bezeichner-Erkennung (Stufe 1 des K7-Detektors, auch von K8 benutzt)
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * @typedef {object} Span
294
+ * @property {string} id Die `id` des Musters aus `IDENTIFIER_PATTERNS`.
295
+ * @property {string} token Der gefundene Text.
296
+ * @property {number} start Index (UTF-16) im übergebenen Text.
297
+ * @property {number} end Index hinter dem letzten Zeichen.
298
+ */
299
+
300
+ /**
301
+ * Alle Bezeichner-Spannen eines Textes, ohne Doppelzählung.
302
+ *
303
+ * Mehrere Muster treffen denselben Text (`docs/telemetry.md` passt sowohl auf
304
+ * das Verzeichnis-Präfix als auch auf die Dateiendung). Enthaltene Treffer
305
+ * werden verworfen, sonst zählte ein Bezeichner zweimal und die K7-Meldung
306
+ * nennte ihn dem Operator doppelt.
307
+ *
308
+ * @param {string} text
309
+ * @returns {Span[]}
310
+ */
311
+ function identifierSpans(text) {
312
+ const found = [];
313
+ for (const { id, pattern } of IDENTIFIER_PATTERNS) {
314
+ const re = globalOf(pattern);
315
+ let match = re.exec(text);
316
+ while (match !== null) {
317
+ if (match[0] === '') {
318
+ re.lastIndex += 1;
319
+ } else {
320
+ // `start`/`end` bleiben am ROHEN Treffer: ein kleinerer Abstand zur
321
+ // Glosse ist die FP-sichere Richtung. Getrimmt wird nur der Text, der
322
+ // gegen die Allowlist geprüft und dem Operator genannt wird.
323
+ const trimmed = match[0].replace(TRAILING_PUNCTUATION, '');
324
+ found.push({
325
+ id,
326
+ token: trimmed === '' ? match[0] : trimmed,
327
+ start: match.index,
328
+ end: match.index + match[0].length,
329
+ });
330
+ }
331
+ match = re.exec(text);
332
+ }
333
+ }
334
+ // Längste Spanne zuerst, damit die enthaltene und nicht die umfassende fällt.
335
+ found.sort((a, b) => a.start - b.start || b.end - a.end);
336
+ /** @type {Span[]} */
337
+ const kept = [];
338
+ for (const span of found) {
339
+ const covered = kept.some((k) => span.start >= k.start && span.end <= k.end);
340
+ if (!covered) kept.push(span);
341
+ }
342
+ return kept;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Glossen-Erkennung (Stufe 3 des K7-Detektors)
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Alle Spannen, die von einem Trenner aus `GLOSS_RULE.delimiters` eingefasst
351
+ * sind. Trenner ohne Schließzeichen (Gedankenstrich, Doppelpunkt) reichen bis
352
+ * zum Satzende — genau so liest ein Mensch sie.
353
+ *
354
+ * @param {string} sentence
355
+ * @returns {Array<{id: string, start: number, end: number}>}
356
+ */
357
+ function delimitedSpans(sentence) {
358
+ const spans = [];
359
+ for (const delim of GLOSS_RULE.delimiters) {
360
+ let from = 0;
361
+ for (;;) {
362
+ const open = sentence.indexOf(delim.open, from);
363
+ if (open === -1) break;
364
+ const innerStart = open + delim.open.length;
365
+ if (delim.close === null) {
366
+ spans.push({ id: delim.id, start: innerStart, end: sentence.length });
367
+ from = innerStart;
368
+ } else {
369
+ const close = sentence.indexOf(delim.close, innerStart);
370
+ if (close === -1) break;
371
+ spans.push({ id: delim.id, start: innerStart, end: close });
372
+ from = close + delim.close.length;
373
+ }
374
+ }
375
+ }
376
+ return spans;
377
+ }
378
+
379
+ /**
380
+ * Die Spannen, die alle inhaltlichen Glossen-Bedingungen erfüllen (3 und 4 aus
381
+ * `GLOSS_RULE`). Bedingung 4 ist die tragende: eine "Erklärung", die selbst aus
382
+ * Bezeichnern besteht, verschiebt das Nachschlagen nur um ein Wort.
383
+ *
384
+ * @param {string} sentence
385
+ * @returns {Array<{id: string, start: number, end: number}>}
386
+ */
387
+ function qualifyingGlosses(sentence) {
388
+ return delimitedSpans(sentence).filter((span) => {
389
+ const inner = sentence.slice(span.start, span.end);
390
+ if (wordsOf(inner).length < GLOSS_RULE.minWords) return false;
391
+ if (GLOSS_RULE.mustContainNoIdentifiers && identifierSpans(inner).length > 0) return false;
392
+ return true;
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Bedingung 2 aus `GLOSS_RULE`: steht die Glosse dicht genug am Bezeichner?
398
+ *
399
+ * @param {Span} token
400
+ * @param {Array<{start: number, end: number}>} glosses
401
+ * @returns {boolean}
402
+ */
403
+ function isGlossed(token, glosses) {
404
+ return glosses.some((gloss) => {
405
+ const after = gloss.start - token.end;
406
+ if (after >= 0 && after <= GLOSS_RULE.maxGapChars) return true;
407
+ const before = token.start - gloss.end;
408
+ return before >= 0 && before <= GLOSS_RULE.maxGapChars;
409
+ });
410
+ }
411
+
412
+ /**
413
+ * Der dreistufige K7-Detektor: erkennen → Allowlist → Glossen-Regel.
414
+ *
415
+ * @param {string} text
416
+ * @returns {string[]} die unerklärten Bezeichner, in Fundreihenfolge
417
+ */
418
+ function unexplainedIdentifiers(text) {
419
+ const out = [];
420
+ for (const sentence of sentencesOf(stripPlaceholders(text))) {
421
+ const glosses = qualifyingGlosses(sentence);
422
+ for (const token of identifierSpans(sentence)) {
423
+ if (IDENTIFIER_ALLOWLIST.has(token.token)) continue;
424
+ if (isGlossed(token, glosses)) continue;
425
+ out.push(token.token);
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Befund-Fabriken — der strukturelle Ort der Sicherheits-Ausnahme
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /**
436
+ * Die Stufe, die ein Kriterium überhaupt erreichen KANN. K2 kann nie `fail`
437
+ * werden, egal wie lang der Satz ist — das steht in der Registry und wird hier
438
+ * erzwungen statt an acht Aufrufstellen wiederholt.
439
+ *
440
+ * @param {string} criterion
441
+ * @param {'fail'|'warn'} wanted
442
+ * @returns {'fail'|'warn'}
443
+ */
444
+ function cappedSeverity(criterion, wanted) {
445
+ return CRITERIA[criterion].severity === 'warn' ? 'warn' : wanted;
446
+ }
447
+
448
+ /**
449
+ * Befund für ein Kriterium, das KEIN Längenkriterium ist.
450
+ *
451
+ * Wirft für K1/K6. Das ist die eine Hälfte der strukturellen Absicherung: ein
452
+ * Längenbefund kann die Sicherheits-Ausnahme nicht versehentlich umgehen, weil
453
+ * er hier gar nicht gebaut werden kann.
454
+ *
455
+ * @param {Parameters<typeof makeFinding>[0]} rec
456
+ * @returns {import('./schema.mjs').AuqFinding}
457
+ */
458
+ function finding(rec) {
459
+ if (LENGTH_CRITERIA.includes(rec.criterion)) {
460
+ throw new TypeError(
461
+ `auq/clarity: ${rec.criterion} ist ein Längenkriterium und muss über lengthFinding() laufen`,
462
+ );
463
+ }
464
+ return makeFinding({ ...rec, severity: cappedSeverity(rec.criterion, rec.severity) });
465
+ }
466
+
467
+ /**
468
+ * Befund für ein Längenkriterium (K1/K6), mit angewandter Sicherheits-Ausnahme.
469
+ *
470
+ * `subject` ist der Text, dessen LÄNGE gemessen wurde. Passt er auf das
471
+ * Sicherheits-Lexikon, wird der Befund auf `warn` herabgestuft und trägt
472
+ * `exempt: 'length'`.
473
+ *
474
+ * Für Befunde ohne gemessenen Text — die Optionsanzahl je Frage — ist `subject`
475
+ * der leere String. `isLengthExempt('')` ist per Vertrag `null`, also kann die
476
+ * Ausnahme die Hürde H2 strukturell nicht aushebeln: eine ZAHL hat keinen Text,
477
+ * der geschützt werden müsste, und ein Sicherheitswort in irgendeiner Option
478
+ * darf nicht dazu führen, dass eine Frage mit 7 Optionen als in Ordnung gilt.
479
+ *
480
+ * @param {Parameters<typeof makeFinding>[0]} rec
481
+ * @param {string} subject
482
+ * @returns {import('./schema.mjs').AuqFinding}
483
+ */
484
+ function lengthFinding(rec, subject) {
485
+ if (!LENGTH_CRITERIA.includes(rec.criterion)) {
486
+ throw new TypeError(
487
+ `auq/clarity: ${rec.criterion} ist kein Längenkriterium und gehört nicht in lengthFinding()`,
488
+ );
489
+ }
490
+ const exempt = isLengthExempt(subject);
491
+ const severity = exempt === null ? cappedSeverity(rec.criterion, rec.severity) : 'warn';
492
+ return makeFinding({ ...rec, severity, exempt });
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // K1 — Payload-Form (Frage)
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /**
500
+ * Zählt die eingebetteten Zeilen, die ein BERICHT sind.
501
+ *
502
+ * Leerzeilen und Aufzählungszeilen zählen NICHT mit. Die Ausnahme ist tragend:
503
+ * `"Ready to create [N] issues?\n\n- [X] critical\n- [Y] high…"` hat fünf
504
+ * eingebettete Zeilen und ist trotzdem in Ordnung — die Liste IST die Tatsache,
505
+ * die der Operator zum Entscheiden braucht. Ohne die Ausnahme meldet K1 die
506
+ * bestgebaute Bestätigungsfrage im Repo als Verstoß.
507
+ *
508
+ * @param {string} text
509
+ * @returns {number}
510
+ */
511
+ function reportLines(text) {
512
+ const segments = str(text).split(NEWLINE_PATTERN);
513
+ let count = 0;
514
+ for (let i = 1; i < segments.length; i += 1) {
515
+ const segment = segments[i];
516
+ if (segment.trim() === '') continue;
517
+ if (LIST_LINE_PATTERN.test(segment)) continue;
518
+ count += 1;
519
+ }
520
+ return count;
521
+ }
522
+
523
+ /**
524
+ * @param {import('./schema.mjs').AuqQuestion} q
525
+ * @param {{file: string, line: number}} at
526
+ * @returns {import('./schema.mjs').AuqFinding[]}
527
+ */
528
+ function checkK1(q, at) {
529
+ const raw = str(q.question);
530
+ if (raw.trim() === '') return [];
531
+ const out = [];
532
+ const base = { criterion: 'K1', severity: /** @type {'fail'} */ ('fail'), target: /** @type {'question'} */ ('question'), ...at, excerpt: raw };
533
+
534
+ const chars = codepointLength(normalizeLiteralNewlines(raw));
535
+ if (chars > THRESHOLDS.K1.questionCharsFail) {
536
+ out.push(
537
+ lengthFinding(
538
+ {
539
+ ...base,
540
+ message:
541
+ `Die Frage ist ${chars} Zeichen lang. Über ${THRESHOLDS.K1.questionCharsFail} liest der ` +
542
+ 'Operator sie als Bericht, nicht als Frage — der Sachstand gehört in die Optionen.' +
543
+ lowerBoundNote(q, raw),
544
+ measured: chars,
545
+ threshold: THRESHOLDS.K1.questionCharsFail,
546
+ },
547
+ raw,
548
+ ),
549
+ );
550
+ }
551
+
552
+ const lines = reportLines(raw);
553
+ if (lines >= THRESHOLDS.K1.embeddedLinesFail) {
554
+ out.push(
555
+ lengthFinding(
556
+ {
557
+ ...base,
558
+ message:
559
+ `Die Frage bringt ${lines} zusätzliche Fließtextzeilen mit. Das ist ein Bericht mit ` +
560
+ 'Fragezeichen. (Aufzählungszeilen zählen nicht mit — die sind lesbare Struktur.)',
561
+ measured: lines,
562
+ threshold: THRESHOLDS.K1.embeddedLinesFail,
563
+ },
564
+ raw,
565
+ ),
566
+ );
567
+ }
568
+
569
+ if (CODE_FENCE_PATTERN.test(raw)) {
570
+ out.push(
571
+ lengthFinding(
572
+ {
573
+ ...base,
574
+ message:
575
+ 'Die Frage enthält einen Codeblock. Der Operator soll entscheiden, nicht Quelltext lesen.',
576
+ measured: 'Codeblock',
577
+ threshold: 'kein Codeblock',
578
+ },
579
+ raw,
580
+ ),
581
+ );
582
+ }
583
+
584
+ if (TABLE_ROW_PATTERN.test(normalizeLiteralNewlines(raw))) {
585
+ out.push(
586
+ lengthFinding(
587
+ {
588
+ ...base,
589
+ message:
590
+ 'Die Frage enthält eine Tabellenzeile. Eine Tabelle ist Material zum Nachschlagen, ' +
591
+ 'keine Frage.',
592
+ measured: 'Tabellenzeile',
593
+ threshold: 'keine Tabelle',
594
+ },
595
+ raw,
596
+ ),
597
+ );
598
+ }
599
+
600
+ return out;
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // K2 — Satzlänge (Frage, Gewicht 0, nur Hinweis)
605
+ // ---------------------------------------------------------------------------
606
+
607
+ /**
608
+ * Nebensignal, absichtlich zahnlos.
609
+ *
610
+ * Der längste Satz im ganzen Korpus hat 27 Wörter: jede Schwelle feuert auf
611
+ * 1–3 von 33 Fragen (Zensus siehe Kopf) und verfehlt die schlechteste Frage
612
+ * vollständig. Als
613
+ * Kriterium wäre das ein Zufallsgenerator, als Hinweis ist es brauchbar —
614
+ * deshalb Gewicht 0 und `severity: 'warn'` in der Registry, was `cappedSeverity`
615
+ * hier erzwingt. Sichtbar, nicht wirksam.
616
+ *
617
+ * @param {import('./schema.mjs').AuqQuestion} q
618
+ * @param {{file: string, line: number}} at
619
+ * @returns {import('./schema.mjs').AuqFinding[]}
620
+ */
621
+ function checkK2(q, at) {
622
+ const sentences = sentencesOf(q.question);
623
+ if (sentences.length === 0) return [];
624
+ let longest = '';
625
+ let longestWords = 0;
626
+ for (const sentence of sentences) {
627
+ const count = wordsOf(sentence).length;
628
+ if (count > longestWords) {
629
+ longestWords = count;
630
+ longest = sentence;
631
+ }
632
+ }
633
+ if (longestWords <= THRESHOLDS.K2.sentenceWordsWarn) return [];
634
+ return [
635
+ finding({
636
+ criterion: 'K2',
637
+ severity: 'warn',
638
+ target: 'question',
639
+ ...at,
640
+ message:
641
+ `Der längste Satz der Frage hat ${longestWords} Wörter. Ab ` +
642
+ `${THRESHOLDS.K2.sentenceWordsWarn} muss der Operator zweimal lesen — ein Hinweis, ` +
643
+ 'kein Fehler.',
644
+ measured: longestWords,
645
+ threshold: THRESHOLDS.K2.sentenceWordsWarn,
646
+ excerpt: longest,
647
+ }),
648
+ ];
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // K3 — Beschreibung wiederholt Label (Option)
653
+ // ---------------------------------------------------------------------------
654
+
655
+ /**
656
+ * KONJUNKTION, nicht Disjunktion — das ist der ganze Inhalt dieses Kriteriums.
657
+ *
658
+ * Containment allein hat 100 % Falsch-Positive: fünf von 120 Paaren (Zensus siehe
659
+ * Kopf) erreichen
660
+ * containment 1.00, und alle fünf sind Einwort-Labels (`new`, `full`, `feature`,
661
+ * `onboarding`), deren Beschreibung reichlich Substanz trägt. Erst
662
+ * "sagt wenig Neues" UND "wiederholt das halbe Label" trifft den echten Fall.
663
+ *
664
+ * @param {import('./schema.mjs').AuqOption} option
665
+ * @param {{file: string, line: number}} at
666
+ * @returns {import('./schema.mjs').AuqFinding[]}
667
+ */
668
+ function checkK3(option, at) {
669
+ const label = str(option.label);
670
+ const description = str(option.description);
671
+ if (description.trim() === '') return [];
672
+ if (isPlaceholderOnly(label) || isPlaceholderOnly(description)) return [];
673
+
674
+ const labelTokens = contentTokens(label);
675
+ if (labelTokens.size === 0) return [];
676
+ const descriptionTokens = contentTokens(description);
677
+
678
+ let repeated = 0;
679
+ for (const token of labelTokens) if (descriptionTokens.has(token)) repeated += 1;
680
+ const containment = repeated / labelTokens.size;
681
+
682
+ let novelty = 0;
683
+ for (const token of descriptionTokens) if (!labelTokens.has(token)) novelty += 1;
684
+
685
+ if (novelty >= THRESHOLDS.K3.noveltyFailBelow) return [];
686
+ if (containment < THRESHOLDS.K3.containmentFailAtOrAbove) return [];
687
+
688
+ const percent = Math.round(containment * MAX_POINTS);
689
+ return [
690
+ finding({
691
+ criterion: 'K3',
692
+ severity: 'fail',
693
+ target: 'option',
694
+ optionIndex: option.index,
695
+ ...at,
696
+ message:
697
+ `Die Beschreibung sagt kaum etwas Neues: ${novelty} neue Wörter, und sie wiederholt ` +
698
+ `${percent} % des Labels. Der Operator erfährt hier nichts, was auf dem Knopf nicht ` +
699
+ 'schon steht.',
700
+ measured: `novelty=${novelty} containment=${containment.toFixed(2)}`,
701
+ threshold: `novelty<${THRESHOLDS.K3.noveltyFailBelow} und containment>=${THRESHOLDS.K3.containmentFailAtOrAbove}`,
702
+ excerpt: `${label} → ${description}`,
703
+ }),
704
+ ];
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // K4 — Empfehlung ohne Grund (Option)
709
+ // ---------------------------------------------------------------------------
710
+
711
+ /**
712
+ * ALLE VIER Markerklassen, und zwar zwingend.
713
+ *
714
+ * Gemessen (Zensus siehe Kopf): mit drei Klassen sind 16 von 29
715
+ * `(Recommended)`-Optionen markiert,
716
+ * 7 davon falsch (44 % FP) — darunter "Fastest for file-disjoint tasks." und
717
+ * "most precise decomposition", die beide sehr wohl einen Grund nennen, nämlich
718
+ * einen Vergleich. Mit der vierten Klasse: 9 von 29, 1 davon falsch (14 %).
719
+ *
720
+ * @param {import('./schema.mjs').AuqOption} option
721
+ * @param {{file: string, line: number}} at
722
+ * @returns {import('./schema.mjs').AuqFinding[]}
723
+ */
724
+ function checkK4(option, at) {
725
+ if (option.isRecommended !== true) return [];
726
+ const description = str(option.description);
727
+ if (isPlaceholderOnly(description)) return [];
728
+
729
+ const matched = MARKER_CLASS_IDS.filter((id) => MARKER_CLASSES[id].pattern.test(description));
730
+ if (matched.length >= THRESHOLDS.K4.markersRequired) return [];
731
+
732
+ const classNames = MARKER_CLASS_IDS.map((id) => MARKER_CLASSES[id].title).join(', ');
733
+ return [
734
+ finding({
735
+ criterion: 'K4',
736
+ severity: 'fail',
737
+ target: 'option',
738
+ optionIndex: option.index,
739
+ ...at,
740
+ message:
741
+ 'Diese Option ist als Empfehlung markiert, nennt aber keinen Grund, keinen Preis, keine ' +
742
+ 'Folge und keinen Vergleich. Eine Empfehlung ohne all das ist eine Behauptung, die der ' +
743
+ 'Operator nur glauben oder ignorieren kann.',
744
+ measured: 0,
745
+ threshold: `mindestens ${THRESHOLDS.K4.markersRequired} von: ${classNames}`,
746
+ excerpt: `${str(option.label)} → ${description}`,
747
+ }),
748
+ ];
749
+ }
750
+
751
+ // ---------------------------------------------------------------------------
752
+ // K5 — Header-Grenze (Hürde H1)
753
+ // ---------------------------------------------------------------------------
754
+
755
+ /**
756
+ * CODEPOINTS, nicht Bytes.
757
+ *
758
+ * `Buffer.byteLength('Evolve — Rev')` ist 14, die Kopfzeile hat aber 12 Zeichen:
759
+ * bytebasiert gemessen reißt sie eine Grenze, die sie nicht reißt, und der
760
+ * Bericht meldet eine Kopfzeile als abgeschnitten, die vollständig ankommt.
761
+ *
762
+ * @param {import('./schema.mjs').AuqQuestion} q
763
+ * @param {{file: string, line: number}} at
764
+ * @returns {{findings: import('./schema.mjs').AuqFinding[], hurdles: string[]}}
765
+ */
766
+ function checkK5(q, at) {
767
+ const header = q.header;
768
+ if (typeof header !== 'string' || header.trim() === '') return { findings: [], hurdles: [] };
769
+
770
+ const literal = codepointLength(header);
771
+
772
+ if (HEADER_PLACEHOLDER_PATTERN.test(header)) {
773
+ const worst = literal + THRESHOLDS.K5.placeholderPad;
774
+ if (worst <= THRESHOLDS.K5.headerCharsFail) return { findings: [], hurdles: [] };
775
+ return {
776
+ findings: [
777
+ finding({
778
+ criterion: 'K5',
779
+ severity: 'warn',
780
+ target: 'header',
781
+ ...at,
782
+ message:
783
+ `Die Kopfzeile enthält einen Platzhalter. Eingesetzt wird sie im ungünstigsten Fall ` +
784
+ `${worst} Zeichen lang und damit über ${THRESHOLDS.K5.headerCharsFail} abgeschnitten — ` +
785
+ 'geprüft werden kann das erst zur Laufzeit.',
786
+ measured: worst,
787
+ threshold: THRESHOLDS.K5.headerCharsFail,
788
+ excerpt: header,
789
+ }),
790
+ ],
791
+ hurdles: [],
792
+ };
793
+ }
794
+
795
+ if (literal <= THRESHOLDS.K5.headerCharsFail) return { findings: [], hurdles: [] };
796
+
797
+ return {
798
+ findings: [
799
+ finding({
800
+ criterion: 'K5',
801
+ severity: 'fail',
802
+ target: 'header',
803
+ ...at,
804
+ message:
805
+ `Die Kopfzeile "${header}" hat ${literal} Zeichen. Das Tool zeigt nur ` +
806
+ `${THRESHOLDS.K5.headerCharsFail} — den Rest sieht der Operator nie.`,
807
+ measured: literal,
808
+ threshold: THRESHOLDS.K5.headerCharsFail,
809
+ hurdle: HURDLES.H1.id,
810
+ excerpt: header,
811
+ }),
812
+ ],
813
+ hurdles: [HURDLES.H1.id],
814
+ };
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // K6 — Längenbudget (Optionen, Nutzlast, Optionsanzahl = Hürde H2)
819
+ // ---------------------------------------------------------------------------
820
+
821
+ /**
822
+ * Optionen zählen PRO FRAGE, nie pro Block.
823
+ *
824
+ * Blockweise gezählt meldet `skills/plan/SKILL.md:137` 14 Optionen und damit
825
+ * einen H2-Bruch — es ist aber ein völlig legaler Vierfragen-Block mit je 3–4
826
+ * Optionen. Ein Bewerter, der hier blockweise zählt, meldet den saubersten
827
+ * mehrteiligen Dialog im Repo als schwersten Verstoß.
828
+ *
829
+ * @param {import('./schema.mjs').AuqQuestion} q
830
+ * @param {{file: string, line: number}} at
831
+ * @returns {{findings: import('./schema.mjs').AuqFinding[], hurdles: string[]}}
832
+ */
833
+ function checkK6(q, at) {
834
+ const options = Array.isArray(q.options) ? q.options : [];
835
+ const findings = [];
836
+ const hurdles = new Set();
837
+
838
+ for (const option of options) {
839
+ const label = str(option.label);
840
+ const description = str(option.description);
841
+ const base = { target: /** @type {'option'} */ ('option'), optionIndex: option.index, ...at };
842
+
843
+ const descriptionChars = codepointLength(description);
844
+ if (descriptionChars > THRESHOLDS.K6.descriptionCharsFail) {
845
+ findings.push(
846
+ lengthFinding(
847
+ {
848
+ ...base,
849
+ criterion: 'K6',
850
+ severity: 'fail',
851
+ message:
852
+ `Die Beschreibung hat ${descriptionChars} Zeichen. Ab ` +
853
+ `${THRESHOLDS.K6.descriptionCharsFail} liest der Operator sie nicht mehr zu Ende — ` +
854
+ 'und entscheidet dann nach dem Label allein.' +
855
+ lowerBoundNote(q, description),
856
+ measured: descriptionChars,
857
+ threshold: THRESHOLDS.K6.descriptionCharsFail,
858
+ excerpt: description,
859
+ },
860
+ description,
861
+ ),
862
+ );
863
+ } else if (descriptionChars > THRESHOLDS.K6.descriptionCharsWarn) {
864
+ findings.push(
865
+ lengthFinding(
866
+ {
867
+ ...base,
868
+ criterion: 'K6',
869
+ severity: 'warn',
870
+ message:
871
+ `Die Beschreibung hat ${descriptionChars} Zeichen und wird ab ` +
872
+ `${THRESHOLDS.K6.descriptionCharsWarn} unhandlich.` +
873
+ lowerBoundNote(q, description),
874
+ measured: descriptionChars,
875
+ threshold: THRESHOLDS.K6.descriptionCharsWarn,
876
+ excerpt: description,
877
+ },
878
+ description,
879
+ ),
880
+ );
881
+ }
882
+
883
+ const labelChars = codepointLength(label);
884
+ if (labelChars > THRESHOLDS.K6.labelCharsWarn) {
885
+ findings.push(
886
+ lengthFinding(
887
+ {
888
+ ...base,
889
+ criterion: 'K6',
890
+ severity: 'warn',
891
+ message:
892
+ `Das Label hat ${labelChars} Zeichen. Über ${THRESHOLDS.K6.labelCharsWarn} wird der ` +
893
+ 'Knopftext zum Fließtext.' +
894
+ lowerBoundNote(q, label),
895
+ measured: labelChars,
896
+ threshold: THRESHOLDS.K6.labelCharsWarn,
897
+ excerpt: label,
898
+ },
899
+ label,
900
+ ),
901
+ );
902
+ }
903
+ }
904
+
905
+ // Nutzlast der ganzen Frage: Fragetext plus alle Labels plus alle Beschreibungen.
906
+ const payloadParts = [str(q.question)];
907
+ for (const option of options) payloadParts.push(str(option.label), str(option.description));
908
+ const payloadText = payloadParts.join(' ');
909
+ const payloadChars = payloadParts.reduce((sum, part) => sum + codepointLength(part), 0);
910
+
911
+ if (payloadChars > THRESHOLDS.K6.questionPayloadFail) {
912
+ findings.push(
913
+ lengthFinding(
914
+ {
915
+ criterion: 'K6',
916
+ severity: 'fail',
917
+ target: 'question',
918
+ ...at,
919
+ message:
920
+ `Die Frage bringt insgesamt ${payloadChars} Zeichen auf den Schirm (Fragetext und alle ` +
921
+ `Optionen). Ab ${THRESHOLDS.K6.questionPayloadFail} ist das keine Entscheidung mehr, ` +
922
+ 'sondern eine Leseaufgabe.' +
923
+ lowerBoundNote(q, payloadText),
924
+ measured: payloadChars,
925
+ threshold: THRESHOLDS.K6.questionPayloadFail,
926
+ excerpt: str(q.question),
927
+ },
928
+ payloadText,
929
+ ),
930
+ );
931
+ } else if (payloadChars > THRESHOLDS.K6.questionPayloadWarn) {
932
+ findings.push(
933
+ lengthFinding(
934
+ {
935
+ criterion: 'K6',
936
+ severity: 'warn',
937
+ target: 'question',
938
+ ...at,
939
+ message:
940
+ `Die Frage bringt insgesamt ${payloadChars} Zeichen auf den Schirm. Ab ` +
941
+ `${THRESHOLDS.K6.questionPayloadWarn} wird die Auswahl mühsam.` +
942
+ lowerBoundNote(q, payloadText),
943
+ measured: payloadChars,
944
+ threshold: THRESHOLDS.K6.questionPayloadWarn,
945
+ excerpt: str(q.question),
946
+ },
947
+ payloadText,
948
+ ),
949
+ );
950
+ }
951
+
952
+ // --- Hürde H2, Teil 1: Optionsanzahl JE FRAGE ---------------------------
953
+ // NICHT PRÜFBAR ist nicht dasselbe wie VERSTOSSEN. parse.mjs hängt
954
+ // `optionCountUnknown: true` an eine Frage, deren Vorlage ein Ellipsen-
955
+ // Fragment enthält ("…up to 4 options per batch…") — die wahre Optionszahl
956
+ // steht dort schlicht nicht. Drei Fragen im Korpus sind betroffen
957
+ // (skills/reconcile/SKILL.md:246, skills/evolve/SKILL.md:251,
958
+ // agents/memory-proposal-collector.md:139). Ein FAIL wäre dort drei erfundene
959
+ // Verstöße — und Falsch-Positive sind für diesen Prüfer teurer als
960
+ // Falsch-Negative. Also ein Hinweis, der die Lücke benennt, und keine Hürde.
961
+ //
962
+ // Das Feld ist ADDITIV: es steht nicht im eingefrorenen `AuqQuestion`-Vertrag,
963
+ // `makeQuestion` kennt es nicht. Deshalb wird strikt auf `=== true` geprüft
964
+ // statt auf Wahrheitswert — fehlt das Feld, ist `undefined` kein "unbekannt".
965
+ const countUnknown = q.optionCountUnknown === true;
966
+
967
+ // `subject` ist hier bewusst der leere String: eine ZAHL hat keinen Text, der
968
+ // vor dem Kürzen geschützt werden müsste. So kann ein Sicherheitswort in
969
+ // irgendeiner Beschreibung die Hürde strukturell nicht aushebeln.
970
+ const count = options.length;
971
+ if (countUnknown) {
972
+ findings.push(
973
+ lengthFinding(
974
+ {
975
+ criterion: 'K6',
976
+ severity: 'warn',
977
+ target: 'question',
978
+ ...at,
979
+ message:
980
+ `In der Vorlage stehen ${count} Optionen, aber sie bricht mit einer Auslassung ab — ` +
981
+ 'wie viele es zur Laufzeit wirklich werden, steht nirgends. Die Grenze von ' +
982
+ `${THRESHOLDS.K6.optionsMin} bis ${THRESHOLDS.K6.optionsMax} Optionen ist hier nicht ` +
983
+ 'prüfbar, nicht verletzt.',
984
+ measured: 'unbekannt',
985
+ threshold: `${THRESHOLDS.K6.optionsMin}-${THRESHOLDS.K6.optionsMax}`,
986
+ excerpt: str(q.question),
987
+ },
988
+ '',
989
+ ),
990
+ );
991
+ } else if (count > THRESHOLDS.K6.optionsMax) {
992
+ findings.push(
993
+ lengthFinding(
994
+ {
995
+ criterion: 'K6',
996
+ severity: 'fail',
997
+ target: 'question',
998
+ ...at,
999
+ message:
1000
+ `Diese Frage bietet ${count} Optionen an. Mehr als ${THRESHOLDS.K6.optionsMax} kann ` +
1001
+ 'niemand gegeneinander abwägen — die hinteren werden überlesen.',
1002
+ measured: count,
1003
+ threshold: THRESHOLDS.K6.optionsMax,
1004
+ hurdle: HURDLES.H2.id,
1005
+ excerpt: str(q.question),
1006
+ },
1007
+ '',
1008
+ ),
1009
+ );
1010
+ hurdles.add(HURDLES.H2.id);
1011
+ } else if (count < THRESHOLDS.K6.optionsMin) {
1012
+ findings.push(
1013
+ lengthFinding(
1014
+ {
1015
+ criterion: 'K6',
1016
+ severity: 'fail',
1017
+ target: 'question',
1018
+ ...at,
1019
+ message:
1020
+ `Diese Frage bietet ${count} Option(en) an. Unter ${THRESHOLDS.K6.optionsMin} gibt es ` +
1021
+ 'nichts zu entscheiden — dann ist es eine Mitteilung, keine Frage.',
1022
+ measured: count,
1023
+ threshold: THRESHOLDS.K6.optionsMin,
1024
+ hurdle: HURDLES.H2.id,
1025
+ excerpt: str(q.question),
1026
+ },
1027
+ '',
1028
+ ),
1029
+ );
1030
+ hurdles.add(HURDLES.H2.id);
1031
+ }
1032
+
1033
+ // --- Hürde H2, Teil 2: die Empfehlung gehört auf Platz 1 ----------------
1034
+ // Bewusst NICHT geprüft: "gar keine Empfehlung". Ob eine Option als empfohlen
1035
+ // erkannt wurde, hängt daran, dass der Parser den Marker im Label findet — ein
1036
+ // fehlendes `isRecommended` kann also genauso gut eine Parser-Lücke oder eine
1037
+ // bewusst empfehlungsfreie Frage sein. Daraus einen harten F-Bruch zu machen,
1038
+ // hieße die einzige Hürde mit gemessener 0-%-FP-Rate zu verwässern.
1039
+ const recommended = options.filter((o) => o && o.isRecommended === true);
1040
+ if (recommended.length > 1) {
1041
+ findings.push(
1042
+ lengthFinding(
1043
+ {
1044
+ criterion: 'K6',
1045
+ severity: 'fail',
1046
+ target: 'question',
1047
+ ...at,
1048
+ message:
1049
+ `${recommended.length} Optionen sind als Empfehlung markiert. Genau eine gehört an die ` +
1050
+ 'erste Stelle — zwei Empfehlungen sind keine Empfehlung.',
1051
+ measured: recommended.length,
1052
+ threshold: 1,
1053
+ hurdle: HURDLES.H2.id,
1054
+ excerpt: str(q.question),
1055
+ },
1056
+ '',
1057
+ ),
1058
+ );
1059
+ hurdles.add(HURDLES.H2.id);
1060
+ } else if (!countUnknown && recommended.length === 1 && options.indexOf(recommended[0]) > 0) {
1061
+ const position = options.indexOf(recommended[0]) + 1;
1062
+ findings.push(
1063
+ lengthFinding(
1064
+ {
1065
+ criterion: 'K6',
1066
+ severity: 'fail',
1067
+ target: 'question',
1068
+ ...at,
1069
+ message:
1070
+ `Die Empfehlung steht an Position ${position} statt an erster Stelle. Der Operator ` +
1071
+ 'liest von oben und nimmt die erste Option als die gemeinte.',
1072
+ measured: position,
1073
+ threshold: 1,
1074
+ hurdle: HURDLES.H2.id,
1075
+ excerpt: str(recommended[0].label),
1076
+ },
1077
+ '',
1078
+ ),
1079
+ );
1080
+ hurdles.add(HURDLES.H2.id);
1081
+ }
1082
+
1083
+ return { findings, hurdles: [...hurdles] };
1084
+ }
1085
+
1086
+ // ---------------------------------------------------------------------------
1087
+ // K7 — Unerklärter Bezeichner (Option)
1088
+ // ---------------------------------------------------------------------------
1089
+
1090
+ /**
1091
+ * ABSOLUTE ZAHL, keine Dichte.
1092
+ *
1093
+ * Die gemessene Bezeichner-Dichte hat Median 0 und Maximum 0,44 und trennt
1094
+ * damit nichts. Schlimmer: eine Dichteschwelle versteckt `.orchestrator/session.lock`
1095
+ * in einem langen Satz — je mehr Prosa drumherum, desto unsichtbarer der
1096
+ * Bezeichner, obwohl der Operator ihn genauso nachschlagen muss.
1097
+ *
1098
+ * Sicherheitstexte sind hiervon NICHT befreit: K7 fügt Information hinzu und
1099
+ * nimmt keine weg. Deshalb läuft dieser Befund über `finding()`, nicht über
1100
+ * `lengthFinding()` — und `finding()` würde ein Längenkriterium ablehnen.
1101
+ *
1102
+ * @param {import('./schema.mjs').AuqOption} option
1103
+ * @param {{file: string, line: number}} at
1104
+ * @returns {import('./schema.mjs').AuqFinding[]}
1105
+ */
1106
+ function checkK7(option, at) {
1107
+ const description = str(option.description);
1108
+ if (isPlaceholderOnly(description)) return [];
1109
+
1110
+ const unexplained = unexplainedIdentifiers(description);
1111
+ if (unexplained.length < THRESHOLDS.K7.unexplainedFailAtOrAbove) return [];
1112
+
1113
+ const named = unexplained.map((t) => `"${t}"`).join(', ');
1114
+ return [
1115
+ finding({
1116
+ criterion: 'K7',
1117
+ severity: 'fail',
1118
+ target: 'option',
1119
+ optionIndex: option.index,
1120
+ ...at,
1121
+ message:
1122
+ `Diese Option nennt ${named}, ohne daneben zu sagen, was das ist. Der Operator müsste ` +
1123
+ 'erst nachschlagen, um überhaupt zu wissen, worüber er entscheidet.',
1124
+ measured: unexplained.length,
1125
+ threshold: THRESHOLDS.K7.unexplainedFailAtOrAbove,
1126
+ excerpt: description,
1127
+ }),
1128
+ ];
1129
+ }
1130
+
1131
+ // ---------------------------------------------------------------------------
1132
+ // K8 — "Geh selbst nachsehen" (Option)
1133
+ // ---------------------------------------------------------------------------
1134
+
1135
+ /**
1136
+ * Die ICH-FORM-AUSSCHLUSSREGEL ist der eigentliche Inhalt dieses Kriteriums.
1137
+ *
1138
+ * "I will inspect the worktree before re-running /close." enthält einen
1139
+ * Lese-Imperativ und einen Pfad in Reichweite — aber dort sieht der AGENT nach,
1140
+ * nicht der Operator. Ohne den Ausschluss meldet K8 genau die Option als
1141
+ * Verstoß, die dem Operator die Arbeit ABNIMMT.
1142
+ *
1143
+ * @param {string} text
1144
+ * @returns {Array<{imperative: string, target: string, sentence: string}>}
1145
+ */
1146
+ function selfServeHits(text) {
1147
+ const hits = [];
1148
+ for (const sentence of sentencesOf(stripPlaceholders(text))) {
1149
+ const targets = identifierSpans(sentence).filter((s) => s.id === 'path' || s.id === 'issue-ref');
1150
+ if (targets.length === 0) continue;
1151
+
1152
+ const re = globalOf(READ_IMPERATIVE_PATTERN);
1153
+ let match = re.exec(sentence);
1154
+ while (match !== null) {
1155
+ if (match[0] === '') {
1156
+ re.lastIndex += 1;
1157
+ match = re.exec(sentence);
1158
+ continue;
1159
+ }
1160
+ const before = sentence.slice(0, match.index);
1161
+ const after = match.index + match[0].length;
1162
+ if (!FIRST_PERSON_EXCLUSION_PATTERN.test(before)) {
1163
+ const target = targets.find((t) => t.start >= after);
1164
+ if (target !== undefined) {
1165
+ const gap = sentence.slice(after, target.start).replace(globalOf(PERIOD), '');
1166
+ if (codepointLength(gap) <= THRESHOLDS.K8.proximityChars) {
1167
+ hits.push({ imperative: match[0], target: target.token, sentence });
1168
+ }
1169
+ }
1170
+ }
1171
+ match = re.exec(sentence);
1172
+ }
1173
+ }
1174
+ return hits;
1175
+ }
1176
+
1177
+ /**
1178
+ * @param {import('./schema.mjs').AuqOption} option
1179
+ * @param {{file: string, line: number}} at
1180
+ * @returns {import('./schema.mjs').AuqFinding[]}
1181
+ */
1182
+ function checkK8(option, at) {
1183
+ const description = str(option.description);
1184
+ if (isPlaceholderOnly(description)) return [];
1185
+
1186
+ const hits = selfServeHits(description);
1187
+ if (hits.length < THRESHOLDS.K8.failAtOrAbove) return [];
1188
+
1189
+ const first = hits[0];
1190
+ return [
1191
+ finding({
1192
+ criterion: 'K8',
1193
+ severity: 'fail',
1194
+ target: 'option',
1195
+ optionIndex: option.index,
1196
+ ...at,
1197
+ message:
1198
+ `Diese Option verlangt, dass der Operator erst "${first.target}" öffnet, um entscheiden zu ` +
1199
+ 'können. Was dort steht, gehört in die Option — sonst ist die Frage nicht beantwortbar, ' +
1200
+ 'ohne die Arbeit zu unterbrechen.',
1201
+ measured: hits.length,
1202
+ threshold: THRESHOLDS.K8.failAtOrAbove,
1203
+ excerpt: first.sentence,
1204
+ }),
1205
+ ];
1206
+ }
1207
+
1208
+ // ---------------------------------------------------------------------------
1209
+ // Punkte
1210
+ // ---------------------------------------------------------------------------
1211
+
1212
+ /**
1213
+ * `points = 100 − Σ CRITERIA[k].weight` über jedes Kriterium mit mindestens
1214
+ * einem NICHT befreiten `fail`-Befund. Jedes Kriterium zieht höchstens EINMAL
1215
+ * ab — sonst bestraft dieselbe Schwäche eine Vier-Options-Frage doppelt so hart
1216
+ * wie eine Zwei-Options-Frage.
1217
+ *
1218
+ * Die zwei Filter (`severity`, `exempt`) sind bewusst UNABHÄNGIG: ein befreiter
1219
+ * Befund wird bereits in `lengthFinding()` auf `warn` herabgestuft, aber wenn
1220
+ * dort je etwas durchrutscht, hält der `exempt`-Filter hier trotzdem.
1221
+ *
1222
+ * @param {import('./schema.mjs').AuqFinding[]} findings
1223
+ * @returns {number}
1224
+ */
1225
+ function pointsFor(findings) {
1226
+ const failed = new Set();
1227
+ for (const f of findings) {
1228
+ if (f.severity !== 'fail') continue;
1229
+ if (f.exempt !== null) continue;
1230
+ failed.add(f.criterion);
1231
+ }
1232
+ let deduction = 0;
1233
+ for (const criterion of failed) deduction += CRITERIA[criterion].weight;
1234
+ return Math.max(0, Math.min(MAX_POINTS, MAX_POINTS - deduction));
1235
+ }
1236
+
1237
+ // ---------------------------------------------------------------------------
1238
+ // Öffentliche API
1239
+ // ---------------------------------------------------------------------------
1240
+
1241
+ /**
1242
+ * Bewertet EINE Frage gegen alle acht Kriterien und beide Hürden.
1243
+ *
1244
+ * @param {import('./schema.mjs').AuqQuestion} question
1245
+ * @param {number} [questionIndex] 0-basierte Position der Frage in ihrem Block.
1246
+ * @returns {import('./schema.mjs').AuqScore}
1247
+ */
1248
+ export function scoreQuestion(question, questionIndex = 0) {
1249
+ if (question === null || typeof question !== 'object') {
1250
+ throw new TypeError('auq/clarity: scoreQuestion braucht eine AuqQuestion');
1251
+ }
1252
+ const at = { file: question.file, line: question.line };
1253
+ const options = Array.isArray(question.options) ? question.options : [];
1254
+
1255
+ /** @type {Record<string, import('./schema.mjs').AuqFinding[]>} */
1256
+ const byCriterion = {};
1257
+ for (const id of CRITERION_IDS) byCriterion[id] = [];
1258
+ const hurdles = new Set();
1259
+
1260
+ byCriterion.K1.push(...checkK1(question, at));
1261
+ byCriterion.K2.push(...checkK2(question, at));
1262
+
1263
+ const k5 = checkK5(question, at);
1264
+ byCriterion.K5.push(...k5.findings);
1265
+ for (const h of k5.hurdles) hurdles.add(h);
1266
+
1267
+ const k6 = checkK6(question, at);
1268
+ byCriterion.K6.push(...k6.findings);
1269
+ for (const h of k6.hurdles) hurdles.add(h);
1270
+
1271
+ for (const option of options) {
1272
+ if (option === null || typeof option !== 'object') continue;
1273
+ byCriterion.K3.push(...checkK3(option, at));
1274
+ byCriterion.K4.push(...checkK4(option, at));
1275
+ byCriterion.K7.push(...checkK7(option, at));
1276
+ byCriterion.K8.push(...checkK8(option, at));
1277
+ }
1278
+
1279
+ // Stabile Reihenfolge nach Kriterium — ein Bericht, dessen Befunde bei jedem
1280
+ // Lauf anders sortiert sind, lässt sich nicht diffen.
1281
+ const findings = CRITERION_IDS.flatMap((id) => byCriterion[id]);
1282
+
1283
+ return makeScore({
1284
+ file: question.file,
1285
+ line: question.line,
1286
+ questionIndex,
1287
+ points: pointsFor(findings),
1288
+ hurdlesBroken: [...hurdles],
1289
+ findings,
1290
+ });
1291
+ }
1292
+
1293
+ /**
1294
+ * Bewertet alle Fragen aller Blöcke, in Fundreihenfolge.
1295
+ *
1296
+ * `questionIndex` ist die Position INNERHALB des Blocks — nicht fortlaufend über
1297
+ * alle Blöcke, weil ein `AuqScore` sonst nicht mehr auf seine Frage zeigt.
1298
+ *
1299
+ * @param {import('./schema.mjs').AuqBlock[]} blocks
1300
+ * @returns {import('./schema.mjs').AuqScore[]}
1301
+ */
1302
+ export function scoreBlocks(blocks) {
1303
+ if (!Array.isArray(blocks)) return [];
1304
+ const scores = [];
1305
+ for (const block of blocks) {
1306
+ if (block === null || typeof block !== 'object') continue;
1307
+ const questions = Array.isArray(block.questions) ? block.questions : [];
1308
+ questions.forEach((question, index) => {
1309
+ if (question === null || typeof question !== 'object') return;
1310
+ scores.push(scoreQuestion(question, index));
1311
+ });
1312
+ }
1313
+ return scores;
1314
+ }