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,1006 @@
1
+ /**
2
+ * auq/parse.mjs — der tolerante Extraktor für die AUQ-Klarheitsmessung (#1107).
3
+ *
4
+ * Findet Operator-Frage-Vorlagen im Repo und liefert `AuqBlock`/`AuqQuestion`/
5
+ * `AuqOption`-Datensätze aus `./schema.mjs`. **Er bewertet nichts** — jede
6
+ * Schwelle, jedes Kriterium und jede Note gehören `clarity.mjs`.
7
+ *
8
+ * ## Warum zeilen- und zeichenbasiert und NICHT über einen AST
9
+ *
10
+ * Weil der Korpus stellenweise gar kein gültiges JavaScript ist. Drei
11
+ * Vorlagen kürzen ihre Optionsliste mit einem Auslassungs-Fragment ab:
12
+ *
13
+ * skills/reconcile/SKILL.md:253 ...up to 4 options per batch...
14
+ * skills/evolve/SKILL.md:258 ...
15
+ * agents/memory-proposal-collector.md ...
16
+ *
17
+ * `acorn` oder `JSON.parse` werfen dort, und zwar auf genau den Blöcken, die
18
+ * am dringendsten gemessen werden wollen. Ein Parser, der an drei echten
19
+ * Fundstellen aussteigt, misst nicht das Repo, sondern seine eigene Toleranz.
20
+ * Der Preis dafür ist bewusst: wir lesen Zeichen, kein Syntaxbaum, und jede
21
+ * Frage, deren Optionszahl durch so ein Fragment unbekannt wird, trägt
22
+ * `optionCountUnknown: true` (siehe unten) statt einer erfundenen Zahl.
23
+ *
24
+ * ## Die sechs Fallen, gegen die hier gebaut ist (alle am Korpus belegt)
25
+ *
26
+ * 1. **Verschachtelter Code-Zaun IM Fragetext.** `skills/discovery/SKILL.md:373`
27
+ * trägt zwei literale ``` mitten im `question`-String. Ein Segmentierer, der
28
+ * Zäune mit /```/ ohne Zeilenanfangs-Anker sucht, schließt den Block mitten
29
+ * in der Frage. Hier ist der Anker `^[ \t]*```` — Pflicht, nicht Stil.
30
+ * 2. **Template-Literale.** Gemessen 2026-08-22: 9 der Fragen stehen in
31
+ * Backticks, und das sind ausgerechnet die fachwortdichtesten im Repo. Wer
32
+ * nur `question: "` sucht, verliert sie. `readStringLiteral()` liest alle
33
+ * drei Anführungsformen und überspringt `${…}` samt darin verschachtelter
34
+ * Strings und Ternaries (`skills/session-start/SKILL.md:161` hat ein `===`
35
+ * im `${…}` — ein Split auf Anführungszeichen zerreißt die Zeile).
36
+ * 3. **Mehrzeilige Options-Objekte.** 20 der 138 `label:`-Zeilen (14,5 %) haben
37
+ * ihr `description:` NICHT auf derselben Zeile. Darum paart dieses Modul
38
+ * Schlüssel nach POSITION im Segment, nie zeilenweise.
39
+ * 4. **JS-Kommentar auf der Feldzeile.** `skills/session-start/phase-2-5-docs-planning.md:60`
40
+ * hat `label: "Dev (Recommended)", // add "(Recommended)" to each …` — der
41
+ * Kommentar trägt selbst ein Anführungspaar. `stripComments()` entfernt ihn
42
+ * VOR dem Lesen, und gelesen wird das ERSTE Literal nach dem Schlüssel.
43
+ * 5. **Ellipsen-Fragmente** — siehe oben, der Grund gegen den AST.
44
+ * 6. **Zaun-Sprache uneinheitlich.** Nur 11 der 40 Blöcke stehen in ```js
45
+ * (27,5 %), 29 in einem sprachlosen Zaun. Es wird deshalb nie auf die
46
+ * Sprache gefiltert, sondern immer auf den Inhalt.
47
+ * 7. **`multiSelect` fehlt** in mehreren Blöcken. Optional, Vorgabe `false` —
48
+ * das erledigt `makeQuestion` an einer Stelle für alle.
49
+ *
50
+ * ## Die Umkehrfalle bei der Empfehlung
51
+ *
52
+ * `scripts/lib/config/dispatcher-autonomy-capture.mjs:59` setzt `(Recommended)`
53
+ * in die **description**, während die Labels nackte Enum-Werte sind (`off` /
54
+ * `advisory` / `autonomous-gated`). Ein Ausdruck, der `label:.*\(Recommended\)`
55
+ * sucht, übersieht die Frage vollständig. `isRecommended` wird darum aus BEIDEN
56
+ * Feldern abgeleitet, und der Fall wird zusätzlich als Warnung durchgereicht
57
+ * (`recommended-in-description`), damit „Empfehlung vorhanden, aber am falschen
58
+ * Feld" sichtbar bleibt statt still zu verschwinden.
59
+ *
60
+ * ## Eine bekannte Untergrenze, die dieses Modul NICHT beheben kann
61
+ *
62
+ * Die 9 Backtick-Fragen enthalten `${…}`-Interpolationen. Die gerenderte Frage
63
+ * ist LÄNGER als ihr Quelltext — `${blockingSession.worktreePath}` (28 Zeichen
64
+ * Quelltext) expandiert zu einem absoluten Pfad von leicht 60+ Zeichen. Jede
65
+ * Längenmessung auf dem hier gelieferten Text ist für genau diese Fragen also
66
+ * ZU LAX; ein Befund ist echt, ein Nicht-Befund beweist nichts.
67
+ *
68
+ * @see ./schema.mjs — der eingefrorene Vertrag (Feldnamen, Aufzählungen, Fabriken)
69
+ * @see .claude/rules/ask-via-tool.md — die Regel, die gemessen wird
70
+ * @see Issue #1107
71
+ */
72
+
73
+ import { execFileSync } from 'node:child_process';
74
+ import { readFileSync } from 'node:fs';
75
+ import path from 'node:path';
76
+
77
+ import {
78
+ makeBlock,
79
+ makeOption,
80
+ makeQuestion,
81
+ emptyCorpus,
82
+ RECOMMENDED_MARKERS,
83
+ isRecommendedOption,
84
+ } from './schema.mjs';
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Korpus-Abgrenzung
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Verzeichnisse, in denen Population A/B leben. `.md` ausserhalb davon
92
+ * (README, CHANGELOG, docs/) beschreibt das Werkzeug, statt den Operator zu
93
+ * fragen — es sind keine Vorlagen, die je gestellt werden.
94
+ */
95
+ export const MD_CORPUS_PREFIXES = Object.freeze([
96
+ 'skills/',
97
+ 'commands/',
98
+ 'agents/',
99
+ '.claude/rules/',
100
+ ]);
101
+
102
+ /** Population C-mdc: die getrackten Cursor-Regeln. */
103
+ export const MDC_CORPUS_PREFIX = '.cursor/rules/';
104
+
105
+ /**
106
+ * Population C-mjs: echtes Produktions-JavaScript. `tests/` ist ausgenommen —
107
+ * eine Frage in einer Fixture wird niemandem gestellt.
108
+ */
109
+ export const MJS_CORPUS_PREFIXES = Object.freeze(['scripts/', 'hooks/', 'skills/']);
110
+
111
+ /**
112
+ * Gehört diese Datei in den Korpus?
113
+ *
114
+ * @param {string} file repo-relativer Pfad (POSIX-Trenner)
115
+ * @returns {'md'|'mdc'|'mjs'|null} die Lesart, oder `null` = nicht im Korpus
116
+ */
117
+ export function corpusKindOf(file) {
118
+ if (typeof file !== 'string' || file === '') return null;
119
+ const f = file.replace(/\\/gu, '/');
120
+ if (f.endsWith('.mdc')) return f.startsWith(MDC_CORPUS_PREFIX) ? 'mdc' : null;
121
+ if (f.endsWith('.md')) {
122
+ return MD_CORPUS_PREFIXES.some((p) => f.startsWith(p)) ? 'md' : null;
123
+ }
124
+ if (f.endsWith('.mjs')) {
125
+ if (f.startsWith('tests/')) return null;
126
+ return MJS_CORPUS_PREFIXES.some((p) => f.startsWith(p)) ? 'mjs' : null;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Zeichen-Primitive: Kommentare, String-Literale, Schlüssel
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Ersetzt JS-Kommentare durch Leerzeichen — LÄNGENERHALTEND, damit jeder
137
+ * Zeichen-Offset weiterhin auf dieselbe Zeile zeigt. Zeilenumbrüche bleiben.
138
+ *
139
+ * Nur so ist Falle 4 sauber lösbar: der Kommentar
140
+ * `// add "(Recommended)" to each detected audience` trägt selbst ein
141
+ * Anführungspaar, das jeder „nimm das letzte Literal der Zeile"-Ansatz als
142
+ * Label extrahiert.
143
+ *
144
+ * @param {string} text
145
+ * @returns {string} gleiche Länge, Kommentarinhalt durch Leerzeichen ersetzt
146
+ */
147
+ export function stripComments(text) {
148
+ if (typeof text !== 'string' || text === '') return '';
149
+ const out = [...text];
150
+ let i = 0;
151
+ while (i < out.length) {
152
+ const c = text[i];
153
+ if (c === '"' || c === "'" || c === '`') {
154
+ const lit = readStringLiteral(text, i);
155
+ i = lit ? lit.end : i + 1;
156
+ continue;
157
+ }
158
+ if (c === '/' && text[i + 1] === '/') {
159
+ while (i < out.length && text[i] !== '\n') out[i++] = ' ';
160
+ continue;
161
+ }
162
+ if (c === '/' && text[i + 1] === '*') {
163
+ out[i] = ' ';
164
+ out[i + 1] = ' ';
165
+ i += 2;
166
+ while (i < out.length && !(text[i] === '*' && text[i + 1] === '/')) {
167
+ if (text[i] !== '\n') out[i] = ' ';
168
+ i++;
169
+ }
170
+ if (i < out.length) {
171
+ out[i] = ' ';
172
+ out[i + 1] = ' ';
173
+ i += 2;
174
+ }
175
+ continue;
176
+ }
177
+ i++;
178
+ }
179
+ return out.join('');
180
+ }
181
+
182
+ /**
183
+ * Liest ein String-Literal ab `pos`. Beherrscht `"`, `'` und Backtick.
184
+ *
185
+ * In einem Backtick-Literal wird `${…}` mitsamt verschachtelter Klammern UND
186
+ * verschachtelter Strings übersprungen und WÖRTLICH in den Wert übernommen —
187
+ * der Quelltext ist das, was gemessen wird, und `skills/session-start/SKILL.md:161`
188
+ * trägt ein Ternary mit `===` und einfachen Anführungszeichen in seiner
189
+ * Interpolation.
190
+ *
191
+ * `"` und `'` bilden BEIDE auf `quoting: 'double'` ab: die Aufzählung
192
+ * `QUOTINGS` kennt nur `double|backtick|prose`, und für die Auszugsform ist ein
193
+ * einfach-zitiertes Literal (die Form in den `.mjs`-Dateien) mit einem
194
+ * doppelt-zitierten identisch. Nur das Template-Literal verhält sich anders,
195
+ * und genau das trennt `backtick` ab.
196
+ *
197
+ * @param {string} text
198
+ * @param {number} pos Index des öffnenden Anführungszeichens
199
+ * @returns {{value: string, quoting: 'double'|'backtick', end: number}|null}
200
+ */
201
+ export function readStringLiteral(text, pos) {
202
+ const q = text[pos];
203
+ if (q !== '"' && q !== "'" && q !== '`') return null;
204
+ let i = pos + 1;
205
+ let out = '';
206
+ while (i < text.length) {
207
+ const c = text[i];
208
+ if (c === '\\') {
209
+ const next = text[i + 1] ?? '';
210
+ // Nur die Anführungs- und Backslash-Escapes auflösen. `\n` bleibt STEHEN,
211
+ // weil schema.NEWLINE_PATTERN genau diese literale Form erkennt.
212
+ out += '"\'`\\'.includes(next) ? next : c + next;
213
+ i += 2;
214
+ continue;
215
+ }
216
+ if (q === '`' && c === '$' && text[i + 1] === '{') {
217
+ let depth = 1;
218
+ let j = i + 2;
219
+ out += '${';
220
+ while (j < text.length && depth > 0) {
221
+ const d = text[j];
222
+ if (d === '"' || d === "'" || d === '`') {
223
+ const inner = readStringLiteral(text, j);
224
+ if (inner) {
225
+ out += text.slice(j, inner.end);
226
+ j = inner.end;
227
+ continue;
228
+ }
229
+ }
230
+ if (d === '{') depth++;
231
+ else if (d === '}') depth--;
232
+ out += d;
233
+ j++;
234
+ }
235
+ i = j;
236
+ continue;
237
+ }
238
+ if (c === q) return { value: out, quoting: q === '`' ? 'backtick' : 'double', end: i + 1 };
239
+ // Ein unbeendetes einzeiliges Literal ist ein abgeschnittenes Fragment,
240
+ // kein Wert — lieber nichts liefern als den halben Rest des Blocks.
241
+ if (q !== '`' && c === '\n') return null;
242
+ out += c;
243
+ i++;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ /** Schlüssel, die in einer AUQ-Struktur überhaupt vorkommen. */
249
+ const KEY_PATTERN = /^[A-Za-z_$][\w$]*$/u;
250
+
251
+ /**
252
+ * Sammelt alle Objektschlüssel (`name:`) in Reihenfolge und ÜBERSPRINGT dabei
253
+ * String-Literale. Genau das trennt einen echten Schlüssel von einem Wort in
254
+ * einem Fragetext (`"Answer the question: …"`) oder in einem Kommentar.
255
+ *
256
+ * @param {string} text kommentarfrei (siehe `stripComments`)
257
+ * @returns {Array<{key: string, at: number, valueAt: number}>}
258
+ */
259
+ export function scanKeys(text) {
260
+ const keys = [];
261
+ let i = 0;
262
+ while (i < text.length) {
263
+ const c = text[i];
264
+ if (c === '"' || c === "'" || c === '`') {
265
+ const lit = readStringLiteral(text, i);
266
+ i = lit ? lit.end : i + 1;
267
+ continue;
268
+ }
269
+ if (/[A-Za-z_$]/u.test(c)) {
270
+ let j = i;
271
+ while (j < text.length && /[\w$]/u.test(text[j])) j++;
272
+ const word = text.slice(i, j);
273
+ let k = j;
274
+ while (k < text.length && (text[k] === ' ' || text[k] === '\t')) k++;
275
+ if (text[k] === ':' && KEY_PATTERN.test(word)) {
276
+ let v = k + 1;
277
+ while (v < text.length && /\s/u.test(text[v])) v++;
278
+ keys.push({ key: word, at: i, valueAt: v });
279
+ }
280
+ i = j;
281
+ continue;
282
+ }
283
+ i++;
284
+ }
285
+ return keys;
286
+ }
287
+
288
+ /**
289
+ * Der String-Wert eines Schlüssels — oder `null`, wenn der Wert kein Literal
290
+ * ist. `scripts/lib/config/dispatcher-autonomy-capture.mjs` legt den Wert auf
291
+ * die NÄCHSTE Zeile; `valueAt` hat den Zeilenumbruch bereits übersprungen.
292
+ *
293
+ * @param {string} text
294
+ * @param {{valueAt: number}} key
295
+ * @returns {{value: string, quoting: 'double'|'backtick'}|null}
296
+ */
297
+ function stringValueOf(text, key) {
298
+ const lit = readStringLiteral(text, key.valueAt);
299
+ return lit ? { value: lit.value, quoting: lit.quoting } : null;
300
+ }
301
+
302
+ /** `true`/`false` hinter einem Schlüssel; `null` wenn keins von beiden. */
303
+ function boolValueOf(text, key) {
304
+ if (text.startsWith('true', key.valueAt)) return true;
305
+ if (text.startsWith('false', key.valueAt)) return false;
306
+ return null;
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Zeilen-Werkzeug
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /** Offsets der Zeilenanfänge — für Offset → 1-basierte Zeilennummer. */
314
+ function lineStartsOf(text) {
315
+ const starts = [0];
316
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
317
+ return starts;
318
+ }
319
+
320
+ /** @param {number[]} starts @param {number} offset @returns {number} 1-basiert */
321
+ function lineOfOffset(starts, offset) {
322
+ let lo = 0;
323
+ let hi = starts.length - 1;
324
+ while (lo < hi) {
325
+ const mid = (lo + hi + 1) >> 1;
326
+ if (starts[mid] <= offset) lo = mid;
327
+ else hi = mid - 1;
328
+ }
329
+ return lo + 1;
330
+ }
331
+
332
+ /**
333
+ * Zaun-Anker. **Zeilenanfangs-verankert** — ohne den Anker schließt
334
+ * `skills/discovery/SKILL.md:373` seinen eigenen Block mitten im Fragetext
335
+ * (Falle 1).
336
+ */
337
+ export const FENCE_LINE_PATTERN = /^[ \t]*(`{3,}|~{3,})[ \t]*([^\s`~]*)[ \t]*$/u;
338
+
339
+ /**
340
+ * Zerlegt eine Markdown-Datei in Code-Zaun-Blöcke.
341
+ *
342
+ * @param {string} content
343
+ * @returns {Array<{openLine: number, closeLine: number, lang: string, bodyLines: string[], bodyStartLine: number}>}
344
+ */
345
+ export function fencesOf(content) {
346
+ const lines = content.split('\n');
347
+ const fences = [];
348
+ let open = null;
349
+ for (let i = 0; i < lines.length; i++) {
350
+ const m = FENCE_LINE_PATTERN.exec(lines[i]);
351
+ if (!m) continue;
352
+ if (open === null) {
353
+ open = { marker: m[1][0], len: m[1].length, lang: m[2] || '', startIdx: i };
354
+ continue;
355
+ }
356
+ // Ein schließender Zaun muss dasselbe Zeichen und mindestens dieselbe
357
+ // Länge haben — sonst beendet ein ```js-Zaun einen ````-Block.
358
+ if (m[1][0] !== open.marker || m[1].length < open.len || m[2]) continue;
359
+ fences.push({
360
+ openLine: open.startIdx + 1,
361
+ closeLine: i + 1,
362
+ lang: open.lang,
363
+ bodyLines: lines.slice(open.startIdx + 1, i),
364
+ bodyStartLine: open.startIdx + 2,
365
+ });
366
+ open = null;
367
+ }
368
+ return fences;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Population A — der Tool-Aufruf
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /** Der Aufruf selbst. Bewusst ohne Sprachfilter (Falle 6). */
376
+ export const AUQ_CALL_PATTERN = /AskUserQuestion\s*\(\s*\{/u;
377
+
378
+ /**
379
+ * Eine Zeile, die nur aus einem Auslassungs-Fragment besteht (Falle 5).
380
+ * `...up to 4 options per batch...` ebenso wie ein nacktes `...`.
381
+ */
382
+ export const ELLIPSIS_LINE_PATTERN = /^[ \t]*(?:\.{3}|…)/u;
383
+
384
+ /**
385
+ * Liest einen Population-A-Körper (JS-artig, aber nicht zwingend gültig).
386
+ *
387
+ * @param {string[]} bodyLines
388
+ * @param {number} bodyStartLine 1-basierte Zeile der ersten Körperzeile
389
+ * @returns {{questions: Array<object>, ellipsisLines: number[]}}
390
+ */
391
+ function parseAuqBody(bodyLines, bodyStartLine) {
392
+ const ellipsisLines = [];
393
+ const kept = bodyLines.map((line, idx) => {
394
+ if (ELLIPSIS_LINE_PATTERN.test(line) && !/[:{}[\]]/u.test(line.replace(/\.{3}|…/gu, ''))) {
395
+ ellipsisLines.push(bodyStartLine + idx);
396
+ return '';
397
+ }
398
+ return line;
399
+ });
400
+
401
+ const text = stripComments(kept.join('\n'));
402
+ const starts = lineStartsOf(text);
403
+ const keys = scanKeys(text);
404
+
405
+ // Fragen-Segmente: jedes `question:` beginnt eines, das nächste beendet es.
406
+ const questionIdx = keys.map((k, i) => (k.key === 'question' ? i : -1)).filter((i) => i >= 0);
407
+ const out = [];
408
+
409
+ for (let n = 0; n < questionIdx.length; n++) {
410
+ const startKeyIdx = questionIdx[n];
411
+ const endKeyIdx = n + 1 < questionIdx.length ? questionIdx[n + 1] : keys.length;
412
+ const segment = keys.slice(startKeyIdx, endKeyIdx);
413
+ const qKey = segment[0];
414
+ const qVal = stringValueOf(text, qKey);
415
+ if (!qVal) continue; // kein Literal → keine Vorlage (z. B. `question: m[2].trim()`)
416
+
417
+ const headerKey = segment.find((k) => k.key === 'header');
418
+ const header = headerKey ? stringValueOf(text, headerKey) : null;
419
+ const msKey = segment.find((k) => k.key === 'multiSelect');
420
+ const multiSelect = msKey ? boolValueOf(text, msKey) : null;
421
+
422
+ const options = [];
423
+ const labelPositions = segment
424
+ .map((k, i) => (k.key === 'label' ? i : -1))
425
+ .filter((i) => i >= 0);
426
+ labelPositions.forEach((li, oi) => {
427
+ const nextLi = oi + 1 < labelPositions.length ? labelPositions[oi + 1] : segment.length;
428
+ const scope = segment.slice(li, nextLi);
429
+ const label = stringValueOf(text, scope[0]);
430
+ if (!label) return;
431
+ const descKey = scope.find((k) => k.key === 'description');
432
+ const prevKey = scope.find((k) => k.key === 'preview');
433
+ const desc = descKey ? stringValueOf(text, descKey) : null;
434
+ const prev = prevKey ? stringValueOf(text, prevKey) : null;
435
+ options.push({
436
+ label: label.value,
437
+ description: desc ? desc.value : '',
438
+ preview: prev ? prev.value : null,
439
+ index: options.length,
440
+ });
441
+ });
442
+
443
+ // Auslassungs-Fragmente INNERHALB dieses Segments machen die Optionszahl
444
+ // unbekannt — H2 darf daraus keinen Verstoß bauen.
445
+ const segStart = qKey.at;
446
+ const segEnd = endKeyIdx < keys.length ? keys[endKeyIdx].at : text.length;
447
+ const segFirstLine = lineOfOffset(starts, segStart) + bodyStartLine - 1;
448
+ const segLastLine = lineOfOffset(starts, Math.max(segStart, segEnd - 1)) + bodyStartLine - 1;
449
+ const optionCountUnknown = ellipsisLines.some((l) => l >= segFirstLine && l <= segLastLine);
450
+
451
+ out.push({
452
+ question: qVal.value,
453
+ quoting: qVal.quoting,
454
+ header: header ? header.value : null,
455
+ multiSelect: multiSelect === true,
456
+ options,
457
+ line: segFirstLine,
458
+ optionCountUnknown,
459
+ });
460
+ }
461
+
462
+ return { questions: out, ellipsisLines };
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Population B / C-mdc / C-hybrid — die nummerierte Auswahlliste
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /** Ein nummerierter Listenpunkt. */
470
+ export const NUMBERED_ITEM_PATTERN = /^[ \t]*(\d+)\.[ \t]+(\S.*)$/u;
471
+
472
+ /**
473
+ * Der Anfang eines Fallback-Blocks — VIER Schreibweisen, alle im Korpus belegt.
474
+ * Ein starrer H3-Matcher findet 2 von 11.
475
+ */
476
+ export const FALLBACK_LEADIN_PATTERN =
477
+ /numbered[ -]markdown[ -]list|numbered[ -]list|ask via numbered|present (?:all )?choices|reply with the number|askuserquestion/iu;
478
+
479
+ /**
480
+ * Ein Hinweis INNERHALB des Zauns, dass hier gewählt wird: eine Zeile, die auf
481
+ * `?` endet, oder eine Aufforderungszeile.
482
+ *
483
+ * Nötig, weil der Anfang zuverlässig ist, das ENDE aber nicht: drei Terminatoren
484
+ * sind im Umlauf (`Reply with the number of your choice.`, eine multiSelect-
485
+ * Variante, und GAR KEINER in `skills/session-end/phase-3-2-docs-verification.md`).
486
+ * Das Ende wird deshalb am schließenden Zaun festgemacht, nie am Text.
487
+ */
488
+ /**
489
+ * Die Anlaufzeile kündigt einen ANDEREN Harness an — dann ist der Zaun der
490
+ * Codex-/Cursor-Fallback (Population B), egal ob `AskUserQuestion` darin
491
+ * vorkommt.
492
+ */
493
+ export const FALLBACK_HARNESS_PATTERN = /\bfallback\b|\bCodex\b|\bCursor\b|\bPi\b/iu;
494
+
495
+ export const CHOICE_CUE_PATTERN = /^[ \t]*(?:Options|Choose one|Auswahl|Optionen)[ \t]*:?[ \t]*$/u;
496
+
497
+ /** Markdown-Auszeichnung im LABEL-Teil entfernen. */
498
+ function stripEmphasis(s) {
499
+ return s
500
+ .replace(/\*\*([^*]+)\*\*/gu, '$1')
501
+ .replace(/\*([^*\n]+)\*/gu, '$1')
502
+ .replace(/^_([^_\n]+)_$/u, '$1')
503
+ .trim();
504
+ }
505
+
506
+ /**
507
+ * Zerlegt einen nummerierten Listenpunkt in Label und Beschreibung.
508
+ *
509
+ * Die Zeilenform variiert dreifach — schlicht (`1. Warten (Recommended) — wait…`),
510
+ * fett (`1. **Behalten (Recommended)** — Keep…`) und mit der Empfehlung
511
+ * AUSSERHALB der Fettung (`1. **Warn + carryover and close** *(Recommended)*`).
512
+ * `(Recommended)` darf deshalb nicht als Teil des Labels vorausgesetzt werden.
513
+ *
514
+ * Auszeichnung wird NUR im Label-Teil entfernt: die Beschreibung trägt echte
515
+ * Sternchen als Glob (`docs/dev/**, docs/adr/**`), die ein globales Strippen
516
+ * zerstören würde.
517
+ *
518
+ * @param {string} itemText Text nach `N. `
519
+ * @returns {{label: string, description: string}}
520
+ */
521
+ export function splitNumberedItem(itemText) {
522
+ const m = /\s(?:—|–|--)\s/u.exec(itemText);
523
+ if (!m) return { label: stripEmphasis(itemText), description: '' };
524
+ return {
525
+ label: stripEmphasis(itemText.slice(0, m.index)),
526
+ description: itemText.slice(m.index + m[0].length).trim(),
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Findet in einem Zaun-Körper die nummerierten Auswahl-LÄUFE.
532
+ *
533
+ * Ein Lauf endet an einer Nicht-Listenzeile oder wenn die Nummerierung wieder
534
+ * bei 1 beginnt — `.cursor/rules/040-discovery.mdc:184-192` trägt beide Fälle
535
+ * in EINEM Zaun (erst eine Befundliste, dann die eigentliche Auswahl).
536
+ *
537
+ * @param {string[]} bodyLines
538
+ * @returns {Array<{items: Array<{n: number, text: string, idx: number}>, startIdx: number}>}
539
+ */
540
+ function numberedRunsOf(bodyLines) {
541
+ const runs = [];
542
+ let cur = null;
543
+ bodyLines.forEach((line, idx) => {
544
+ const m = NUMBERED_ITEM_PATTERN.exec(line);
545
+ if (!m) {
546
+ if (line.trim() !== '') cur = null;
547
+ return;
548
+ }
549
+ const n = Number(m[1]);
550
+ if (cur === null || n === 1 || n !== cur.items[cur.items.length - 1].n + 1) {
551
+ cur = { items: [], startIdx: idx };
552
+ runs.push(cur);
553
+ }
554
+ cur.items.push({ n, text: m[2].trim(), idx });
555
+ });
556
+ return runs.filter((r) => r.items.length >= 2);
557
+ }
558
+
559
+ /**
560
+ * Der Fragetext zu einem Lauf: der letzte zusammenhängende Absatz oberhalb,
561
+ * unter Überspringen einer reinen Aufforderungszeile (`Options:`). Findet sich
562
+ * keiner im Zaun, dient die Anlaufzeile über dem Zaun als Frage.
563
+ */
564
+ function questionTextForRun(bodyLines, run, leadIn) {
565
+ let i = run.startIdx - 1;
566
+ const skippable = (l) => l.trim() === '' || CHOICE_CUE_PATTERN.test(l) || NUMBERED_ITEM_PATTERN.test(l);
567
+ // Leerzeilen, Aufforderungszeilen UND einen davorliegenden nummerierten Lauf
568
+ // überspringen: `.cursor/rules/040-discovery.mdc:184` legt erst eine
569
+ // Befundliste und dann `Options:` zwischen die Frage und die Auswahl. Ohne
570
+ // dieses Überspringen bleibt der Fragetext leer.
571
+ while (i >= 0 && skippable(bodyLines[i])) i--;
572
+ const para = [];
573
+ while (i >= 0 && bodyLines[i].trim() !== '' && !NUMBERED_ITEM_PATTERN.test(bodyLines[i])) {
574
+ para.unshift(bodyLines[i].trim());
575
+ i--;
576
+ }
577
+ const text = para.join(' ').trim();
578
+ if (text !== '') return text;
579
+ return leadIn.replace(/^[-*>\s]+/u, '').replace(/\*\*/gu, '').trim();
580
+ }
581
+
582
+ /**
583
+ * Trägt dieser Zaun (oder seine Anlaufzeilen) einen Auswahl-Hinweis?
584
+ *
585
+ * Vier unabhängige Hinweise, weil KEINER allein reicht:
586
+ * (a) eine Aufforderungszeile (`Options:` / `Choose one:`)
587
+ * (b) eine Frage im Zaun (Zeile endet auf `?`)
588
+ * (c) ein Terminator (`Reply with the number …`)
589
+ * (d) ein kanonischer `(Recommended)`-Marker in einem Listenpunkt
590
+ * (e) eine Anlaufzeile, die den Fallback ankündigt
591
+ *
592
+ * (d) trägt allein zwei Fundstellen, die sonst durchfallen —
593
+ * `.cursor/rules/050-plan.mdc:164` (Anlaufzeile „ask for final approval",
594
+ * keine Frage im Zaun) und `.cursor/rules/040-discovery.mdc:196` (die Frage
595
+ * endet auf `)` statt auf `?`).
596
+ *
597
+ * Der Gegentest ist ebenso wichtig: `.cursor/rules/050-plan.mdc:81`
598
+ * („## Answers So Far") ist eine nummerierte ZUSAMMENFASSUNG, keine Auswahl —
599
+ * sie erfüllt keinen der fünf Hinweise und fällt korrekt heraus.
600
+ */
601
+ function hasChoiceCue(bodyLines, leadInLines, runs) {
602
+ if (bodyLines.some((l) => CHOICE_CUE_PATTERN.test(l))) return true;
603
+ if (bodyLines.some((l) => /\?[ \t]*$/u.test(l) && !NUMBERED_ITEM_PATTERN.test(l))) return true;
604
+ if (bodyLines.some((l) => /reply with the number/iu.test(l))) return true;
605
+ if (runs.some((r) => r.items.some((it) => hasRecommendedMarker(it.text)))) return true;
606
+ return leadInLines.some((l) => FALLBACK_LEADIN_PATTERN.test(l));
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Vorlage vs. Illustration
611
+ // ---------------------------------------------------------------------------
612
+
613
+ /** Eine „so sieht das Format aus"-Vorzeile. */
614
+ export const EXAMPLE_LEADIN_PATTERN = /^[ \t>*_-]*(?:Example|Beispiel|Schema|Format)[ \t]*:?[ \t*_]*$/iu;
615
+
616
+ /** Reine Platzhalter-Labels: `X`, `Y`, `Option A`, `<foo>`, `[bar]`, `…`. */
617
+ const PLACEHOLDER_LABEL_PATTERN = /^(?:option\s+[a-z]|[a-z]|…|\.{3})$/iu;
618
+
619
+ /**
620
+ * Ist dieser Block eine Format-Erläuterung statt einer echten Vorlage?
621
+ *
622
+ * Zwei mechanische Merkmale, beide mehrfach im Korpus belegt — KEINE Pfadliste:
623
+ *
624
+ * - `example-leadin`: eine Zeile `Example:` / `Beispiel:` unmittelbar über dem
625
+ * Zaun (trifft `skills/session-start/presentation-format.md` und
626
+ * `.cursor/rules/050-plan.mdc`).
627
+ * - `placeholder-only`: Frage und/oder ALLE Labels bestehen nach Abzug von
628
+ * `<…>`, `[…]` und `…` nur noch aus Platzhaltern (trifft
629
+ * `.claude/rules/ask-via-tool.md`, `skills/_shared/platform-tools.md`,
630
+ * `.cursor/rules/000-session-orchestrator.mdc`).
631
+ *
632
+ * **Bekannte Lücke, bewusst offen:** `skills/grill/SKILL.md:66` ist eine
633
+ * Illustration, weil ihre Domäne (Orders, line-item, partial-refund) im Repo
634
+ * nicht existiert — gemessen 2026-08-22: `line-item` und `partial-refund`
635
+ * kommen in 0 Dateien ausserhalb `skills/grill/` vor. Ein solcher
636
+ * Begriffs-Erdungstest bräuchte einen repo-weiten Term-Index, träfe genau
637
+ * EINE bekannte Stelle und würde jede Vorlage mit eigenem Fachbegriff
638
+ * („Telemetrie", „worktree") falsch verdächtigen. Der Preis übersteigt den
639
+ * Ertrag; die Stelle wird als `template` geführt und hier benannt.
640
+ *
641
+ * @returns {'template'|'illustration'}
642
+ */
643
+ function classifyKind({ leadInLines, questionText, optionLabels }) {
644
+ if (leadInLines.some((l) => EXAMPLE_LEADIN_PATTERN.test(l))) return 'illustration';
645
+
646
+ // Leerraum wird VERDICHTET, nicht entfernt: `"Option A"` ohne Leerzeichen
647
+ // wäre `OptionA` und fiele durch das Platzhaltermuster.
648
+ const bare = (s) =>
649
+ String(s)
650
+ .replace(/<[^<>]{0,60}>/gu, ' ')
651
+ .replace(/\[[^[\]]{0,60}\]/gu, ' ')
652
+ .replace(/[…?.:!*_`"']/gu, ' ')
653
+ .replace(/\s+/gu, ' ')
654
+ .trim();
655
+
656
+ if (bare(questionText) === '') return 'illustration';
657
+ if (optionLabels.length > 0 && optionLabels.every((l) => PLACEHOLDER_LABEL_PATTERN.test(bare(l)))) {
658
+ return 'illustration';
659
+ }
660
+ return 'template';
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Empfehlung
665
+ // ---------------------------------------------------------------------------
666
+
667
+ /** Trägt der Text einen kanonischen Empfehlungs-Marker? */
668
+ function hasRecommendedMarker(text) {
669
+ return RECOMMENDED_MARKERS.some((m) => String(text).includes(m));
670
+ }
671
+
672
+ /** `(Recommended for pros)` u. Ä. — Empfehlung gemeint, Marker nicht kanonisch. */
673
+ const NEAR_RECOMMENDED_PATTERN = /\((?:Recommended|Empfohlen)[^)]*\)/iu;
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // parseFile
677
+ // ---------------------------------------------------------------------------
678
+
679
+ /**
680
+ * Extrahiert alle Frage-Vorlagen aus EINER Datei.
681
+ *
682
+ * Wirft nie: eine unlesbare Struktur wird zur Warnung, nicht zum Abbruch — ein
683
+ * Extraktor, der auf einer Datei stirbt, meldet den Rest des Repos als fehlerfrei.
684
+ *
685
+ * @param {{file: string, content: string}} arg
686
+ * @returns {{blocks: import('./schema.mjs').AuqBlock[], warnings: string[]}}
687
+ */
688
+ export function parseFile({ file, content } = {}) {
689
+ const warnings = [];
690
+ if (typeof file !== 'string' || typeof content !== 'string') {
691
+ return { blocks: [], warnings: ['parseFile: file und content müssen Strings sein'] };
692
+ }
693
+ const kind = corpusKindOf(file);
694
+ if (kind === null) return { blocks: [], warnings };
695
+
696
+ try {
697
+ if (kind === 'mjs') return { blocks: parseMjsFile(file, content, warnings), warnings };
698
+ return { blocks: parseMarkdownFile(file, content, kind, warnings), warnings };
699
+ } catch (err) {
700
+ warnings.push(`${file}: Extraktion abgebrochen — ${err?.message ?? String(err)}`);
701
+ return { blocks: [], warnings };
702
+ }
703
+ }
704
+
705
+ /** Baut eine Frage über die Fabrik und hängt den Unbekannt-Merker an. */
706
+ function buildQuestion(rec, optionCountUnknown, warnings) {
707
+ const options = rec.options.map((o) => {
708
+ const recommendedInLabel = hasRecommendedMarker(o.label);
709
+ const recommendedInDescription = hasRecommendedMarker(o.description);
710
+ if (recommendedInDescription && !recommendedInLabel) {
711
+ // Die Umkehrfalle: dispatcher-autonomy-capture.mjs setzt (Recommended)
712
+ // in die description. Die Empfehlung ist DA, nur am falschen Feld.
713
+ warnings.push(
714
+ `${rec.file}:${rec.line}: recommended-in-description — Option ${o.index} („${o.label}") trägt (Recommended) in der Beschreibung statt im Label`,
715
+ );
716
+ } else if (
717
+ !recommendedInLabel &&
718
+ !recommendedInDescription &&
719
+ (NEAR_RECOMMENDED_PATTERN.test(o.label) || NEAR_RECOMMENDED_PATTERN.test(o.description))
720
+ ) {
721
+ warnings.push(
722
+ `${rec.file}:${rec.line}: near-recommended — Option ${o.index} („${o.label}") nutzt eine nicht-kanonische Empfehlungsform (erwartet: ${RECOMMENDED_MARKERS.join(' oder ')})`,
723
+ );
724
+ }
725
+ // Ein Ort für das Prädikat (schema.mjs). Die beiden lokalen Flags bleiben, weil
726
+ // die Warnungen oben zwischen „Marker im Label" und „Marker in der Beschreibung"
727
+ // unterscheiden müssen — das Urteil selbst kommt aber aus dem geteilten Prädikat.
728
+ return makeOption({ ...o, isRecommended: isRecommendedOption(o) });
729
+ });
730
+
731
+ const question = makeQuestion({ ...rec, options });
732
+ // `optionCountUnknown` ist KEIN Vertragsfeld — schema.mjs friert AuqQuestion
733
+ // ein und kennt es nicht. Es wird additiv angehängt (alle Vertragsfelder
734
+ // stammen weiterhin unverändert aus der Fabrik), damit clarity.mjs die Hürde
735
+ // H2 für ein abgekürztes Options-Fragment nicht als Verstoß meldet.
736
+ return optionCountUnknown ? Object.freeze({ ...question, optionCountUnknown: true }) : question;
737
+ }
738
+
739
+ /** Population A/B/C-mdc/C-hybrid/C-prose. */
740
+ function parseMarkdownFile(file, content, kind, warnings) {
741
+ const lines = content.split('\n');
742
+ const fences = fencesOf(content);
743
+ const blocks = [];
744
+ const fenceRanges = fences.map((f) => [f.openLine, f.closeLine]);
745
+
746
+ for (const fence of fences) {
747
+ const leadInLines = lines.slice(Math.max(0, fence.openLine - 4), fence.openLine - 1);
748
+ const body = fence.bodyLines;
749
+
750
+ if (AUQ_CALL_PATTERN.test(body.join('\n'))) {
751
+ const { questions } = parseAuqBody(body, fence.bodyStartLine);
752
+ if (questions.length === 0) {
753
+ warnings.push(`${file}:${fence.openLine}: AskUserQuestion-Zaun ohne lesbare Frage`);
754
+ continue;
755
+ }
756
+ const built = questions.map((q) =>
757
+ buildQuestion(
758
+ {
759
+ question: q.question,
760
+ header: q.header,
761
+ multiSelect: q.multiSelect,
762
+ options: q.options,
763
+ file,
764
+ line: q.line,
765
+ population: 'A',
766
+ kind: classifyKind({
767
+ leadInLines,
768
+ questionText: q.question,
769
+ optionLabels: q.options.map((o) => o.label),
770
+ }),
771
+ quoting: q.quoting,
772
+ },
773
+ q.optionCountUnknown,
774
+ warnings,
775
+ ),
776
+ );
777
+ blocks.push(makeBlock({ file, line: fence.openLine, questions: built }));
778
+ continue;
779
+ }
780
+
781
+ const runs = numberedRunsOf(body);
782
+ if (runs.length === 0 || !hasChoiceCue(body, leadInLines, runs)) continue;
783
+
784
+ // Population: `.mdc` ist immer C-mdc. In `.md` entscheidet die Anlaufzeile,
785
+ // und zwar in DIESER Reihenfolge — die Fallback-Ankündigung schlägt die
786
+ // blosse Erwähnung des Werkzeugs:
787
+ // „Codex CLI / Cursor IDE fallback (numbered Markdown list)" → B
788
+ // „use `AskUserQuestion` with these options" → C-hybrid
789
+ // Umgekehrt geprüft landeten `skills/_shared/platform-tools.md:39` und
790
+ // `skills/session-start/SKILL.md:1069` fälschlich in C-hybrid: beide sind
791
+ // Fallbacks, deren Anlaufzeile den Werkzeugnamen nur nennt, um ihn
792
+ // auszuschliessen.
793
+ const population =
794
+ kind === 'mdc'
795
+ ? 'C-mdc'
796
+ : leadInLines.some((l) => FALLBACK_HARNESS_PATTERN.test(l))
797
+ ? 'B'
798
+ : leadInLines.some((l) => /AskUserQuestion/u.test(l))
799
+ ? 'C-hybrid'
800
+ : 'B';
801
+
802
+ const built = runs.map((run) => {
803
+ const questionText = questionTextForRun(body, run, leadInLines[leadInLines.length - 1] ?? '');
804
+ const options = run.items.map((it, i) => ({ ...splitNumberedItem(it.text), preview: null, index: i }));
805
+ return buildQuestion(
806
+ {
807
+ question: questionText,
808
+ header: null,
809
+ multiSelect: body.some((l) => /number\(s\)|comma-separated|Mehrfachauswahl/iu.test(l)),
810
+ options,
811
+ file,
812
+ line: fence.bodyStartLine + run.startIdx,
813
+ population,
814
+ kind: classifyKind({
815
+ leadInLines,
816
+ questionText,
817
+ optionLabels: options.map((o) => o.label),
818
+ }),
819
+ quoting: 'prose',
820
+ },
821
+ false,
822
+ warnings,
823
+ );
824
+ });
825
+ blocks.push(makeBlock({ file, line: fence.openLine, questions: built }));
826
+ }
827
+
828
+ // C-prose: eine Frage, die NUR im Fließtext beschrieben ist — erkennbar an
829
+ // einer Zeile ausserhalb jedes Zauns, die den Tool-Namen UND ein `header:`
830
+ // trägt (skills/session-end/phase-3-6-tail.md).
831
+ lines.forEach((line, idx) => {
832
+ const lineNo = idx + 1;
833
+ if (fenceRanges.some(([a, b]) => lineNo >= a && lineNo <= b)) return;
834
+ if (!/AskUserQuestion/u.test(line) || !/\bheader\s*:/u.test(line)) return;
835
+ const keys = scanKeys(stripComments(line));
836
+ const headerKey = keys.find((k) => k.key === 'header');
837
+ const header = headerKey ? stringValueOf(line, headerKey) : null;
838
+ blocks.push(
839
+ makeBlock({
840
+ file,
841
+ line: lineNo,
842
+ questions: [
843
+ buildQuestion(
844
+ {
845
+ question: line.trim(),
846
+ header: header ? header.value : null,
847
+ multiSelect: /multiSelect:\s*true/u.test(line),
848
+ options: [],
849
+ file,
850
+ line: lineNo,
851
+ population: 'C-prose',
852
+ kind: 'template',
853
+ quoting: 'prose',
854
+ },
855
+ false,
856
+ warnings,
857
+ ),
858
+ ],
859
+ }),
860
+ );
861
+ });
862
+
863
+ return blocks;
864
+ }
865
+
866
+ /** Population C-mjs — echte JS-Objektliterale in Produktionscode. */
867
+ function parseMjsFile(file, content, warnings) {
868
+ const text = stripComments(content);
869
+ const starts = lineStartsOf(text);
870
+ const keys = scanKeys(text);
871
+ const questionIdx = keys.map((k, i) => (k.key === 'question' ? i : -1)).filter((i) => i >= 0);
872
+ const blocks = [];
873
+
874
+ for (let n = 0; n < questionIdx.length; n++) {
875
+ const startKeyIdx = questionIdx[n];
876
+ const endKeyIdx = n + 1 < questionIdx.length ? questionIdx[n + 1] : keys.length;
877
+ const segment = keys.slice(startKeyIdx, endKeyIdx);
878
+ const qVal = stringValueOf(text, segment[0]);
879
+ if (!qVal) continue;
880
+
881
+ const labelPositions = segment.map((k, i) => (k.key === 'label' ? i : -1)).filter((i) => i >= 0);
882
+ // Ohne mindestens zwei beschriftete Optionen ist es keine Operator-Frage,
883
+ // sondern ein gleichnamiges Feld — scripts/lib/state-md/body-sections.mjs
884
+ // führt `question:` in einer STATE.md-Datenstruktur.
885
+ if (labelPositions.length < 2) continue;
886
+
887
+ const headerKey = segment.find((k) => k.key === 'header');
888
+ const header = headerKey ? stringValueOf(text, headerKey) : null;
889
+ const msKey = segment.find((k) => k.key === 'multiSelect');
890
+ const options = [];
891
+ labelPositions.forEach((li, oi) => {
892
+ const nextLi = oi + 1 < labelPositions.length ? labelPositions[oi + 1] : segment.length;
893
+ const scope = segment.slice(li, nextLi);
894
+ const label = stringValueOf(text, scope[0]);
895
+ if (!label) return;
896
+ const descKey = scope.find((k) => k.key === 'description');
897
+ const prevKey = scope.find((k) => k.key === 'preview');
898
+ const desc = descKey ? stringValueOf(text, descKey) : null;
899
+ const prev = prevKey ? stringValueOf(text, prevKey) : null;
900
+ options.push({
901
+ label: label.value,
902
+ description: desc ? desc.value : '',
903
+ preview: prev ? prev.value : null,
904
+ index: options.length,
905
+ });
906
+ });
907
+
908
+ const line = lineOfOffset(starts, segment[0].at);
909
+ blocks.push(
910
+ makeBlock({
911
+ file,
912
+ line,
913
+ questions: [
914
+ buildQuestion(
915
+ {
916
+ question: qVal.value,
917
+ header: header ? header.value : null,
918
+ multiSelect: msKey ? boolValueOf(text, msKey) === true : false,
919
+ options,
920
+ file,
921
+ line,
922
+ population: 'C-mjs',
923
+ kind: classifyKind({
924
+ leadInLines: [],
925
+ questionText: qVal.value,
926
+ optionLabels: options.map((o) => o.label),
927
+ }),
928
+ quoting: qVal.quoting,
929
+ },
930
+ false,
931
+ warnings,
932
+ ),
933
+ ],
934
+ }),
935
+ );
936
+ }
937
+ return blocks;
938
+ }
939
+
940
+ // ---------------------------------------------------------------------------
941
+ // parseRepo
942
+ // ---------------------------------------------------------------------------
943
+
944
+ /**
945
+ * Die Dateiliste des Korpus. **`git ls-files`** — nur getrackte Dateien.
946
+ *
947
+ * Gemessen 2026-08-22 auf HEAD a4f93cf: `git ls-files | grep -c '^\.claude/worktrees/'`
948
+ * liefert 0. Ungetrackte Arbeitsbäume sind damit STRUKTURELL ausgeschlossen,
949
+ * nicht per Filter — ein Filter müsste gepflegt werden, die Trackung nicht.
950
+ *
951
+ * @param {string} repoRoot
952
+ * @returns {string[]}
953
+ */
954
+ export function corpusFiles(repoRoot) {
955
+ const out = execFileSync('git', ['ls-files', '-z'], {
956
+ cwd: repoRoot,
957
+ encoding: 'utf8',
958
+ maxBuffer: 64 * 1024 * 1024,
959
+ });
960
+ return out.split('\0').filter((f) => f !== '' && corpusKindOf(f) !== null);
961
+ }
962
+
963
+ /**
964
+ * Extrahiert alle Frage-Vorlagen des Repos.
965
+ *
966
+ * @param {{repoRoot: string, files?: string[]}} arg
967
+ * @returns {{blocks: import('./schema.mjs').AuqBlock[], corpus: Record<string, number>, warnings: string[]}}
968
+ */
969
+ export function parseRepo({ repoRoot, files } = {}) {
970
+ const warnings = [];
971
+ const corpus = emptyCorpus();
972
+ const blocks = [];
973
+
974
+ let list = Array.isArray(files) ? files : null;
975
+ if (list === null) {
976
+ try {
977
+ list = corpusFiles(repoRoot);
978
+ } catch (err) {
979
+ warnings.push(`parseRepo: git ls-files fehlgeschlagen — ${err?.message ?? String(err)}`);
980
+ return { blocks, corpus, warnings };
981
+ }
982
+ }
983
+
984
+ for (const file of list) {
985
+ if (corpusKindOf(file) === null) continue;
986
+ let content;
987
+ try {
988
+ content = readFileSync(path.join(repoRoot, file), 'utf8');
989
+ } catch (err) {
990
+ warnings.push(`${file}: nicht lesbar — ${err?.message ?? String(err)}`);
991
+ continue;
992
+ }
993
+ const res = parseFile({ file, content });
994
+ warnings.push(...res.warnings);
995
+ for (const block of res.blocks) {
996
+ blocks.push(block);
997
+ // `corpus` zählt BLÖCKE je Population — die Herkunftsklasse ist per
998
+ // Konstruktion für alle Fragen eines Blocks dieselbe, und die
999
+ // Korpus-Erhebung vom 2026-08-22 ist ebenfalls in Blöcken angegeben.
1000
+ const population = block.questions[0]?.population;
1001
+ if (population !== undefined) corpus[population] += 1;
1002
+ }
1003
+ }
1004
+
1005
+ return { blocks, corpus, warnings };
1006
+ }