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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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
|
+
}
|