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,787 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pre-auq-clarity.mjs — PreToolUse hook on `AskUserQuestion`.
|
|
4
|
+
*
|
|
5
|
+
* Checks the questions this system is about to put in front of the operator,
|
|
6
|
+
* at the moment they are asked, and blocks the two that the tool itself
|
|
7
|
+
* mangles: a header longer than the tool renders (H1) and an option count /
|
|
8
|
+
* recommendation placement the operator cannot weigh (H2).
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists beside the template gate
|
|
11
|
+
*
|
|
12
|
+
* Wave 2 built the deterministic scorer (`scripts/lib/auq/`), wave 3 brought all
|
|
13
|
+
* 72 TEMPLATES in the repo to 100 %, and a sibling gate now stops a NEW template
|
|
14
|
+
* landing with a broken hurdle. All three act on text that sits in a file.
|
|
15
|
+
*
|
|
16
|
+
* The other half never does: the coordinator formulates questions freshly in
|
|
17
|
+
* every session, from prose, and no template gate can see them. This hook is the
|
|
18
|
+
* only point at which a runtime-composed question can be measured before the
|
|
19
|
+
* operator reads it.
|
|
20
|
+
*
|
|
21
|
+
* ## EVIDENCE STATUS — code-evidence, not runtime-evidence
|
|
22
|
+
*
|
|
23
|
+
* That `AskUserQuestion` reaches `PreToolUse` at all was read out of the shipped
|
|
24
|
+
* Claude Code bundle (2.1.239): exactly one tool is exempted from the hook path
|
|
25
|
+
* (`EndConversation`), and the stdin envelope carries `tool_name` +
|
|
26
|
+
* `tool_input`, with the whole `questions` array inside `tool_input`.
|
|
27
|
+
*
|
|
28
|
+
* **No hook with a matcher of `AskUserQuestion` has ever run in this repo.** The
|
|
29
|
+
* end-to-end proof needs a session restart and is OUTSTANDING. What IS proven
|
|
30
|
+
* without one is everything downstream of stdin — the hook was driven with real
|
|
31
|
+
* envelopes on stdin and its allow/deny envelopes read back (see the test file).
|
|
32
|
+
* Treat the delivery of the envelope as expected-but-unconfirmed and the
|
|
33
|
+
* decision logic as measured.
|
|
34
|
+
*
|
|
35
|
+
* ## What blocks, and what deliberately does not
|
|
36
|
+
*
|
|
37
|
+
* | class | decision | why |
|
|
38
|
+
* |----------------------------------------|----------|-----|
|
|
39
|
+
* | H1 — header over 12 codepoints | **DENY** | the tool truncates it, INVISIBLY: the operator sees a mangled headline and cannot tell it was cut. Auto-shortening loses meaning the same invisible way — a model can write a good short header, a regex cannot. |
|
|
40
|
+
* | H2 — options per question / recommendation not first | **DENY** | dropping or reordering options is a meaning decision, never a normalisation |
|
|
41
|
+
* | K1, K3, K4, K7, K8 (content criteria) | ALLOW | measured false-positive rates 14–25 %. A hook that blocks one correct question in four is switched off within a month — and takes the hard limits with it. Reported on stderr, never denied. |
|
|
42
|
+
* | K2, and every `warn` finding | ALLOW | advisory by construction (`CRITERIA.K2.weight === 0`) |
|
|
43
|
+
* | anything this hook cannot fully parse | ALLOW | see § Fail direction |
|
|
44
|
+
*
|
|
45
|
+
* ## Fail direction — this guard fails OPEN, on purpose
|
|
46
|
+
*
|
|
47
|
+
* A false positive here destroys the operator's question: `emitDeny` blocks the
|
|
48
|
+
* tool call, so the card is never rendered and the operator is never asked. A
|
|
49
|
+
* false negative is a badly-worded question the operator can still answer. The
|
|
50
|
+
* blast radius is asymmetric, so every doubt resolves to ALLOW: an unrecognised
|
|
51
|
+
* payload shape, a malformed option, a scorer throw, a module that fails to
|
|
52
|
+
* load, an unreadable stdin. None of them denies.
|
|
53
|
+
*
|
|
54
|
+
* That extends to this hook's own adapter. A question is analysed only when its
|
|
55
|
+
* shape is FULLY recognised — `questions` an array, `options` an array, every
|
|
56
|
+
* option an object with a string label. Dropping one malformed option out of two
|
|
57
|
+
* would turn a legal question into an H2 break ("fewer than 2 options"), i.e. a
|
|
58
|
+
* deny manufactured by the adapter rather than found in the question. So a
|
|
59
|
+
* partially-recognised question is skipped whole.
|
|
60
|
+
*
|
|
61
|
+
* ## Why `emitRewrite` is NOT wired here
|
|
62
|
+
*
|
|
63
|
+
* `emitRewrite` (scripts/lib/io.mjs) can replace the tool input outright, which
|
|
64
|
+
* would let this hook repair instead of refuse. It is deliberately unused,
|
|
65
|
+
* because every repair the two hurdles admit costs meaning that the operator
|
|
66
|
+
* cannot see going missing:
|
|
67
|
+
*
|
|
68
|
+
* - shortening a 23-character header — which 11 characters are the ones to
|
|
69
|
+
* lose? The operator would read a truncated headline and have no way to know
|
|
70
|
+
* it was truncated. That is the exact failure H1 exists to prevent, moved
|
|
71
|
+
* one layer earlier.
|
|
72
|
+
* - moving the recommended option to position 1 — the ORDER is content. A
|
|
73
|
+
* description written for position 3 can refer to the options above it;
|
|
74
|
+
* lifting it changes what the operator reads first, silently.
|
|
75
|
+
* - deleting the 5th option, or one of two `(Recommended)` markers — which
|
|
76
|
+
* one was meant? Only the author knows.
|
|
77
|
+
*
|
|
78
|
+
* NAMED TRIGGER for switching it on: a finding whose repair has exactly ONE
|
|
79
|
+
* admissible target form AND removes, reorders or rewrites no model-authored
|
|
80
|
+
* text — i.e. a pure character normalisation inside a single field. None of the
|
|
81
|
+
* eight criteria currently produces one. Should `scripts/lib/auq/` ever gain such
|
|
82
|
+
* a finding class (a literal `\n` escape where a line break was meant is the
|
|
83
|
+
* shape to watch for), route exactly that class through `emitRewrite` and leave
|
|
84
|
+
* everything else on `emitDeny` — because for every other finding the MODEL can
|
|
85
|
+
* make the judgement call and a transform cannot.
|
|
86
|
+
*
|
|
87
|
+
* ## stdout discipline
|
|
88
|
+
*
|
|
89
|
+
* Under the exit-0 PreToolUse protocol (#906, ADR-0011) allow and deny share
|
|
90
|
+
* exit code 0; the decision lives only in the stdout JSON. `emitAllow` writes
|
|
91
|
+
* nothing, `emitDeny` writes through `writeStdoutLineSync` and clamps at
|
|
92
|
+
* 16 000 chars. This module never calls `console.log`. Diagnostics go to stderr,
|
|
93
|
+
* which under exit 0 is a DEBUG LOG ONLY — invisible to the operator (see
|
|
94
|
+
* `docs/plugin-architecture-v3.md`). Nothing here may rely on being read.
|
|
95
|
+
*
|
|
96
|
+
* ## Cost
|
|
97
|
+
*
|
|
98
|
+
* The protocol caps a call at 4 questions × 4 options
|
|
99
|
+
* (`.claude/rules/ask-via-tool.md` § AUQ-003); scoring that is microseconds. The
|
|
100
|
+
* only real cost is process start plus parsing the two `auq/` modules. No file
|
|
101
|
+
* is read, no subprocess is spawned, no git command runs — `hooks.json` allows
|
|
102
|
+
* 5 s and this path does not approach it.
|
|
103
|
+
*
|
|
104
|
+
* ## PSA
|
|
105
|
+
*
|
|
106
|
+
* No git command, read-only or otherwise (PSA-007). The only filesystem write is
|
|
107
|
+
* the append-only telemetry line to `.orchestrator/metrics/events.jsonl` (see
|
|
108
|
+
* § Telemetry) — never a repo file, and never anything a sibling session could
|
|
109
|
+
* be mid-edit on.
|
|
110
|
+
*
|
|
111
|
+
* ## Telemetry
|
|
112
|
+
*
|
|
113
|
+
* Every SCORED call emits exactly one `orchestrator.auq_clarity.*` event; the two
|
|
114
|
+
* fail-open paths emit one too, so an unchecked question is distinguishable from
|
|
115
|
+
* a checked-and-clean one after the fact. Without this the hook is unfalsifiable:
|
|
116
|
+
* allow and deny share exit code 0 under ADR-0011, so from outside the process
|
|
117
|
+
* "never fired" and "fired and allowed" are the SAME observation.
|
|
118
|
+
*
|
|
119
|
+
* The payload carries SHAPES ONLY — counts, hurdle ids, criterion tallies. No
|
|
120
|
+
* header, label, description or question text ever leaves this process. That is
|
|
121
|
+
* a privacy boundary, not a size optimisation: the operator's question text is
|
|
122
|
+
* the one thing a clarity guard necessarily sees in full, and `events.jsonl` is
|
|
123
|
+
* read by fleet-wide audits that were never scoped to hold it.
|
|
124
|
+
*
|
|
125
|
+
* Telemetry is best-effort at every call site and can never alter the verdict —
|
|
126
|
+
* awaited BEFORE the terminal emit, because `emitAllow`/`emitDeny` call
|
|
127
|
+
* `process.exit()` and never return, which would discard a pending async write.
|
|
128
|
+
*
|
|
129
|
+
* hooks.json registration is NOT part of this file's change set — see the
|
|
130
|
+
* sibling agent's wiring change.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
import path from 'node:path';
|
|
134
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
135
|
+
import { realpathSync } from 'node:fs';
|
|
136
|
+
|
|
137
|
+
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Late-bound repo dependencies (#993)
|
|
141
|
+
//
|
|
142
|
+
// Static imports fail at ESM LINK time: node exits 1 with 0 bytes on stdout, and
|
|
143
|
+
// under the exit-0 protocol a 0-byte stdout is indistinguishable from an explicit
|
|
144
|
+
// allow — the guard would fail open AND silently. Binding late turns the
|
|
145
|
+
// link-time crash into a catchable runtime error, which is what makes the GUARD
|
|
146
|
+
// INACTIVE banner reachable at all.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
149
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
150
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
151
|
+
/** @type {typeof import('../scripts/lib/events.mjs').emitEvent} */ let emitEvent;
|
|
152
|
+
/** @type {typeof import('../scripts/lib/auq/schema.mjs')} */ let schemaMod;
|
|
153
|
+
/** @type {typeof import('../scripts/lib/auq/clarity.mjs')} */ let clarityMod;
|
|
154
|
+
|
|
155
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
156
|
+
|
|
157
|
+
/** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
|
|
158
|
+
const HOOK_NAME = 'pre-auq-clarity';
|
|
159
|
+
|
|
160
|
+
/** The only tool this hook acts on. Everything else allows immediately. */
|
|
161
|
+
const AUQ_TOOL = 'AskUserQuestion';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `file` recorded on the synthesised `AuqQuestion`. The scorer's schema requires
|
|
165
|
+
* one; a runtime question has no source file, and a fake path would read as a
|
|
166
|
+
* real location in the finding text the model gets back.
|
|
167
|
+
*/
|
|
168
|
+
export const RUNTIME_ORIGIN = '<AskUserQuestion>';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-line ceiling for a rendered finding, in characters.
|
|
172
|
+
*
|
|
173
|
+
* The outer clamp in `emitDeny` (16 000) already keeps the ENVELOPE inside the
|
|
174
|
+
* kernel pipe buffer, so this bound is not about delivery — it is about
|
|
175
|
+
* ORDERING. A finding message interpolates model-authored text (`Die Kopfzeile
|
|
176
|
+
* "…" hat N Zeichen`), so one pathological header could otherwise consume the
|
|
177
|
+
* whole budget and push every other hurdle's line past the outer clamp. 300 is
|
|
178
|
+
* ~1.5× the longest message this scorer produces at the protocol's own field
|
|
179
|
+
* caps, so no legitimate line is ever clipped.
|
|
180
|
+
*/
|
|
181
|
+
export const MAX_LINE_CHARS = 300;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Ceiling on rendered finding lines across the whole call.
|
|
185
|
+
*
|
|
186
|
+
* Worst case at the protocol caps (4 questions, 4 options each) is roughly
|
|
187
|
+
* 4 × (1 header + 4 description + 1 payload + 1 count + 1 recommendation) = 32.
|
|
188
|
+
* 16 keeps the reason readable while still naming more than any single question
|
|
189
|
+
* can break; the overflow is COUNTED in the text, never silently dropped.
|
|
190
|
+
*/
|
|
191
|
+
export const MAX_FINDING_LINES = 16;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993).
|
|
195
|
+
*/
|
|
196
|
+
const GUARD_CONSEQUENCE = {
|
|
197
|
+
inactive: [
|
|
198
|
+
' Consequence: runtime AskUserQuestion clarity checking is OFF — a question',
|
|
199
|
+
' with a truncated header or an unweighable option set CAN now reach the',
|
|
200
|
+
' operator unchecked. Template-level checking is unaffected. This is a',
|
|
201
|
+
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
207
|
+
* is one of the ones that may have failed to load.
|
|
208
|
+
*
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
211
|
+
function bannerProjectDir() {
|
|
212
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Bind every repo dependency late, making a load failure VISIBLE instead of a
|
|
217
|
+
* silent exit-1 disarm. Throws on any load failure; the entry-point catch
|
|
218
|
+
* banners. Banner-only: no module opts into the `git show HEAD:` fallback,
|
|
219
|
+
* because failing open here is cheap (§ Fail direction) and a HEAD copy of a
|
|
220
|
+
* scorer is not obviously better than no scorer.
|
|
221
|
+
*
|
|
222
|
+
* `requires` lists FUNCTION exports only — `assertShape` tests `typeof ===
|
|
223
|
+
* 'function'`, so naming a frozen constant there would fail every load.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Promise<void>}
|
|
226
|
+
*/
|
|
227
|
+
async function bootstrap() {
|
|
228
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
229
|
+
|
|
230
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
231
|
+
const { modules } = await armGuard(
|
|
232
|
+
{
|
|
233
|
+
io: { specifier: lib('io.mjs'), requires: ['readStdin', 'emitAllow', 'emitDeny'] },
|
|
234
|
+
// Telemetry is in the ARMING manifest, not loaded opportunistically at the
|
|
235
|
+
// call site, and that is deliberate. A guard whose firing rate nothing
|
|
236
|
+
// records is unfalsifiable (`.claude/rules/host-resources.md` § HR-105:
|
|
237
|
+
// one such verdict stayed wrong for four months because it reached the
|
|
238
|
+
// metrics store for 0.9 % of sessions). Under `armGuard` a broken
|
|
239
|
+
// events.mjs disarms this hook LOUDLY via the GUARD INACTIVE banner;
|
|
240
|
+
// loaded lazily it would instead disarm the measurement SILENTLY, and a
|
|
241
|
+
// silent measurement gap looks exactly like a clean run. Same placement as
|
|
242
|
+
// the sibling deny-capable guard, `hooks/pre-bash-destructive-guard.mjs`.
|
|
243
|
+
events: { specifier: lib('events.mjs'), requires: ['emitEvent'] },
|
|
244
|
+
schema: {
|
|
245
|
+
specifier: lib('auq', 'schema.mjs'),
|
|
246
|
+
requires: ['makeQuestion', 'makeOption'],
|
|
247
|
+
},
|
|
248
|
+
clarity: { specifier: lib('auq', 'clarity.mjs'), requires: ['scoreQuestion'] },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
hookName: HOOK_NAME,
|
|
252
|
+
repoRoot: PLUGIN_ROOT,
|
|
253
|
+
projectDir: bannerProjectDir(),
|
|
254
|
+
consequence: GUARD_CONSEQUENCE,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
({ readStdin, emitAllow, emitDeny } = modules.io);
|
|
259
|
+
({ emitEvent } = modules.events);
|
|
260
|
+
schemaMod = modules.schema;
|
|
261
|
+
clarityMod = modules.clarity;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Adapter — runtime payload → the scorer's AuqQuestion
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Convert ONE runtime question from `tool_input.questions` into the frozen
|
|
270
|
+
* `AuqQuestion` the scorer takes, or `null` when its shape is not fully
|
|
271
|
+
* recognised.
|
|
272
|
+
*
|
|
273
|
+
* `null` means ALLOW, never "empty question" — see § Fail direction for why a
|
|
274
|
+
* partially-recognised question is skipped whole rather than repaired.
|
|
275
|
+
*
|
|
276
|
+
* `isRecommended` is derived from the label because that is where the marker
|
|
277
|
+
* lives at runtime; the marker list comes from the schema registry
|
|
278
|
+
* (`RECOMMENDED_MARKERS`), so this adapter adds no second spelling of it. When
|
|
279
|
+
* the registry is unavailable the derivation degrades to "no recommendation
|
|
280
|
+
* detected", which can only REMOVE an H2 break, never invent one.
|
|
281
|
+
*
|
|
282
|
+
* @param {unknown} raw one entry of `tool_input.questions`
|
|
283
|
+
* @param {number} index its position, recorded as the `line`
|
|
284
|
+
* @param {object} schema the `auq/schema.mjs` module namespace
|
|
285
|
+
* @returns {object|null}
|
|
286
|
+
*/
|
|
287
|
+
export function toAuqQuestion(raw, index, schema) {
|
|
288
|
+
if (raw === null || typeof raw !== 'object') return null;
|
|
289
|
+
if (typeof raw.question !== 'string') return null;
|
|
290
|
+
if (!Array.isArray(raw.options)) return null;
|
|
291
|
+
|
|
292
|
+
const options = [];
|
|
293
|
+
for (let i = 0; i < raw.options.length; i += 1) {
|
|
294
|
+
const o = raw.options[i];
|
|
295
|
+
// One unrecognised option disqualifies the whole question: dropping it would
|
|
296
|
+
// lower the option COUNT, which is itself an H2 hurdle — a deny invented by
|
|
297
|
+
// this adapter rather than found in the question.
|
|
298
|
+
if (o === null || typeof o !== 'object' || typeof o.label !== 'string') return null;
|
|
299
|
+
options.push(schema.makeOption({
|
|
300
|
+
label: o.label,
|
|
301
|
+
description: typeof o.description === 'string' ? o.description : '',
|
|
302
|
+
preview: typeof o.preview === 'string' ? o.preview : null,
|
|
303
|
+
// Geteiltes Prädikat aus schema.mjs — NICHT `markers.some(m => label.includes(m))`.
|
|
304
|
+
// Diese Zeile prüfte nur das Label und liess damit eine Frage durch, die der
|
|
305
|
+
// Datei-Validator als H2-Bruch meldete: der Marker steht in manchen Vorlagen in
|
|
306
|
+
// der Beschreibung. Zwei Ableitungen derselben Regel, gemessen divergent (W4-Q7).
|
|
307
|
+
isRecommended: schema.isRecommendedOption(o),
|
|
308
|
+
index: i,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return schema.makeQuestion({
|
|
313
|
+
question: raw.question,
|
|
314
|
+
header: typeof raw.header === 'string' ? raw.header : null,
|
|
315
|
+
multiSelect: raw.multiSelect === true,
|
|
316
|
+
options,
|
|
317
|
+
file: RUNTIME_ORIGIN,
|
|
318
|
+
// `line` is the schema's location field and must be an integer; the question's
|
|
319
|
+
// 1-based position is the only location a runtime call has.
|
|
320
|
+
line: index + 1,
|
|
321
|
+
// The corpus populations describe where a question was FOUND. 'A' — an
|
|
322
|
+
// executed tool call — is the one a runtime call actually is.
|
|
323
|
+
population: 'A',
|
|
324
|
+
kind: 'template',
|
|
325
|
+
// 'prose' and not 'backtick': the text here is the real text, not a source
|
|
326
|
+
// literal, so the escape-sequence lower-bound note must NOT fire.
|
|
327
|
+
quoting: 'prose',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Rendering
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Name the exact place a finding sits, so the second attempt lands on the same
|
|
337
|
+
* field rather than somewhere adjacent.
|
|
338
|
+
*
|
|
339
|
+
* @param {object} f an `AuqFinding`
|
|
340
|
+
* @param {number} questionNo 1-based question position
|
|
341
|
+
* @returns {string}
|
|
342
|
+
*/
|
|
343
|
+
function locationOf(f, questionNo) {
|
|
344
|
+
// The JSON path INTO `tool_input`, not a prose location: the reader of this
|
|
345
|
+
// text is the model that has to emit a corrected payload, and a path it can
|
|
346
|
+
// address is what makes the second attempt land on the same field.
|
|
347
|
+
const base = `questions[${questionNo - 1}]`;
|
|
348
|
+
if (f.target === 'header') return `Frage ${questionNo} · ${base}.header`;
|
|
349
|
+
if (f.target === 'option') return `Frage ${questionNo} · ${base}.options[${f.optionIndex}]`;
|
|
350
|
+
return `Frage ${questionNo} · ${base}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Render one finding as a single deny line.
|
|
355
|
+
*
|
|
356
|
+
* The measured value and the limit are appended MECHANICALLY from the finding's
|
|
357
|
+
* own `measured`/`threshold` fields rather than trusted to appear in the prose.
|
|
358
|
+
* The requirement is that a deny names the field, the actual value and the
|
|
359
|
+
* limit; leaning on the message wording alone would make that requirement
|
|
360
|
+
* dependent on a sentence a future edit could rephrase.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} f
|
|
363
|
+
* @param {number} questionNo
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function renderFinding(f, questionNo) {
|
|
367
|
+
const measured = f.measured === null ? '?' : String(f.measured);
|
|
368
|
+
const threshold = f.threshold === null ? '?' : String(f.threshold);
|
|
369
|
+
const head = ` • ${locationOf(f, questionNo)}: `;
|
|
370
|
+
const tail = ` [gemessen: ${measured}, Grenze: ${threshold}]`;
|
|
371
|
+
|
|
372
|
+
// Nur `f.message` wird geklippt — Ort und Messwerte sind reserviert.
|
|
373
|
+
//
|
|
374
|
+
// Vorher klippte diese Funktion die FERTIGE Zeile, und `clipLine` schneidet
|
|
375
|
+
// von hinten. Der Messwert-Suffix steht am Ende, also fiel genau er als
|
|
376
|
+
// erstes weg — und zwar UMSO SICHERER, je gravierender der Verstoß war.
|
|
377
|
+
// Gemessen 2026-08-22 am echten Hook: Kopfzeile 100 und 150 Zeichen → Grund
|
|
378
|
+
// trägt `[gemessen/Grenze]`; Kopfzeile 400 Zeichen → trägt es NICHT MEHR.
|
|
379
|
+
// 400 ist der Fall, für den H1 überhaupt existiert.
|
|
380
|
+
//
|
|
381
|
+
// Der Schaden ist nicht Kosmetik: der Leser dieses Textes ist das Modell, das
|
|
382
|
+
// eine korrigierte Nutzlast schicken muss. Ohne Ist-Wert und Grenze weiß es
|
|
383
|
+
// nicht, WIE VIEL zu kürzen ist, rät, trifft dieselbe Wand — und der Operator
|
|
384
|
+
// erfährt von keinem der beiden Anläufe.
|
|
385
|
+
//
|
|
386
|
+
// Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
|
|
387
|
+
const room = Math.max(24, MAX_LINE_CHARS - [...head].length - [...tail].length);
|
|
388
|
+
const msg = [...String(f.message ?? '')];
|
|
389
|
+
const body = msg.length <= room ? msg.join('') : `${msg.slice(0, room - 1).join('')}…`;
|
|
390
|
+
return `${head}${body}${tail}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Decision — PURE. Returns a verdict; emits nothing, exits nothing.
|
|
395
|
+
//
|
|
396
|
+
// The purity is load-bearing: `emitDeny` and `emitAllow` both call
|
|
397
|
+
// `process.exit()` and never return, so an emit reached from inside the checking
|
|
398
|
+
// flow would terminate before a later question could be judged.
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @typedef {{action: 'allow'|'deny', reason?: string, suggestion?: string, notes: string[]}} Verdict
|
|
403
|
+
*/
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Decide whether this `AskUserQuestion` call may proceed.
|
|
407
|
+
*
|
|
408
|
+
* Denies if and only if at least one HARD HURDLE (H1/H2) is broken on at least
|
|
409
|
+
* one question. Content criteria are collected into `notes` and never affect the
|
|
410
|
+
* action.
|
|
411
|
+
*
|
|
412
|
+
* ## Known limitation, with its revisit trigger (BV-004)
|
|
413
|
+
*
|
|
414
|
+
* The registry maps a hurdle to a CRITERION (`CRITERIA.K5.hurdle === 'H1'`,
|
|
415
|
+
* `CRITERIA.K6.hurdle === 'H2'`), not to an individual finding — so when H2 is
|
|
416
|
+
* broken, the K6 `fail` findings listed alongside it may include two that carry
|
|
417
|
+
* no hurdle of their own (a description over 150 characters, a question payload
|
|
418
|
+
* over 650). They are listed, never decisive: without a broken hurdle they
|
|
419
|
+
* cannot produce a deny at all. Every such line still names its field, value and
|
|
420
|
+
* limit, so the reason stays actionable rather than merely longer.
|
|
421
|
+
*
|
|
422
|
+
* REVISIT TRIGGER: if `scripts/lib/auq/clarity.mjs` ever tags a finding with the
|
|
423
|
+
* hurdle it breaks, switch the selection below from criterion to that tag and
|
|
424
|
+
* delete this note.
|
|
425
|
+
*
|
|
426
|
+
* @param {object} input the parsed PreToolUse payload
|
|
427
|
+
* @param {{schema: object, clarity: object}} lib the two `auq/` module namespaces
|
|
428
|
+
* @returns {Verdict}
|
|
429
|
+
*/
|
|
430
|
+
export function decide(input, lib) {
|
|
431
|
+
const notes = [];
|
|
432
|
+
// `telemetry: null` means NOTHING WAS SCORED, which is a distinct outcome to
|
|
433
|
+
// scored-and-clean; the two must not collapse into one event. The early
|
|
434
|
+
// returns below all take that branch; only the terminal allow/deny carry real
|
|
435
|
+
// counts.
|
|
436
|
+
//
|
|
437
|
+
// (Phrased without the words `from` + a quoted string on one line: the
|
|
438
|
+
// hooks static-import guard in tests/hooks/on-stop.test.mjs scans source
|
|
439
|
+
// textually and reads that shape inside a COMMENT as a third-party import.)
|
|
440
|
+
const allow = (telemetry = null) => ({ action: /** @type {'allow'} */ ('allow'), notes, telemetry });
|
|
441
|
+
|
|
442
|
+
if (input?.tool_name !== AUQ_TOOL) return allow();
|
|
443
|
+
|
|
444
|
+
const schema = lib?.schema;
|
|
445
|
+
const clarity = lib?.clarity;
|
|
446
|
+
if (typeof schema?.makeQuestion !== 'function' || typeof clarity?.scoreQuestion !== 'function') {
|
|
447
|
+
notes.push('Bewerter nicht verfügbar — Frage unverändert durchgelassen.');
|
|
448
|
+
return allow();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const toolInput = input?.tool_input;
|
|
452
|
+
if (toolInput === null || typeof toolInput !== 'object') return allow();
|
|
453
|
+
if (!Array.isArray(toolInput.questions) || toolInput.questions.length === 0) return allow();
|
|
454
|
+
|
|
455
|
+
const hurdles = schema.HURDLES ?? {};
|
|
456
|
+
const criteria = schema.CRITERIA ?? {};
|
|
457
|
+
|
|
458
|
+
// Grouped by hurdle, not by question: two questions breaking H1 are ONE
|
|
459
|
+
// problem with two witnesses, and a reason that repeats the H1 heading twice
|
|
460
|
+
// reads as two unrelated rules. Insertion order is the registry's (H1 then H2)
|
|
461
|
+
// because that is the order the first breaking question hits them in.
|
|
462
|
+
/** @type {Map<string, {title: string, lines: string[]}>} */
|
|
463
|
+
const broken = new Map();
|
|
464
|
+
/** @type {Map<string, number>} */
|
|
465
|
+
const softByCriterion = new Map();
|
|
466
|
+
let skipped = 0;
|
|
467
|
+
|
|
468
|
+
toolInput.questions.forEach((raw, index) => {
|
|
469
|
+
let score;
|
|
470
|
+
try {
|
|
471
|
+
const question = toAuqQuestion(raw, index, schema);
|
|
472
|
+
if (question === null) { skipped += 1; return; }
|
|
473
|
+
score = clarity.scoreQuestion(question, index);
|
|
474
|
+
} catch {
|
|
475
|
+
// A scorer throw is not a violation. Fail open, count it, move on.
|
|
476
|
+
skipped += 1;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const findings = Array.isArray(score?.findings) ? score.findings : [];
|
|
481
|
+
const questionNo = index + 1;
|
|
482
|
+
|
|
483
|
+
for (const hurdleId of Array.isArray(score?.hurdlesBroken) ? score.hurdlesBroken : []) {
|
|
484
|
+
const hurdle = hurdles[hurdleId];
|
|
485
|
+
// Auf dem TAG des Befunds filtern, nicht auf dem Kriterium der Hürde.
|
|
486
|
+
//
|
|
487
|
+
// Vorher stand hier `f.criterion === hurdle.criterion`. Das griff über:
|
|
488
|
+
// K6 erzeugt vier Befundklassen (Beschreibungslänge, Labellänge,
|
|
489
|
+
// Nutzlast, Optionszahl) und nur die letzte reißt H2. Gemessen mit
|
|
490
|
+
// 4 Fragen × 5 Optionen: unter der H2-Überschrift standen zehn
|
|
491
|
+
// Beschreibungslängen-Zeilen und EINE, die den echten Bruch benannte —
|
|
492
|
+
// die Brüche der Fragen 2 bis 4 fielen komplett aus dem Zeilenbudget.
|
|
493
|
+
// Das Modell erfuhr von einem Bruch und musste dreimal nachliefern.
|
|
494
|
+
//
|
|
495
|
+
// `f.hurdle` wird seit dieser Session in `clarity.mjs` gesetzt, wo die
|
|
496
|
+
// Zuordnung ohnehin entsteht. Der Rückfallpfad auf das Kriterium bleibt
|
|
497
|
+
// für Befunde ohne Tag erhalten, damit ein älterer Bewerter nicht
|
|
498
|
+
// stillschweigend leere Abschnitte erzeugt.
|
|
499
|
+
const criterion = hurdle?.criterion;
|
|
500
|
+
const tagged = findings.filter((f) => f.severity === 'fail' && f.hurdle === hurdleId);
|
|
501
|
+
const pool = tagged.length > 0
|
|
502
|
+
? tagged
|
|
503
|
+
: findings.filter((f) => f.severity === 'fail' && f.criterion === criterion);
|
|
504
|
+
const lines = pool.map((f) => renderFinding(f, questionNo));
|
|
505
|
+
// Nach FRAGE gebündelt, nicht flach angehängt. Das Budget wird unten
|
|
506
|
+
// reihum vergeben, und dafür muss sichtbar bleiben, welche Zeile zu
|
|
507
|
+
// welcher Frage gehört — sonst verdrängt eine geschwätzige Frage die
|
|
508
|
+
// Brüche aller anderen (siehe die Begründung an der Vergabe unten).
|
|
509
|
+
const group = broken.get(hurdleId);
|
|
510
|
+
if (group === undefined) {
|
|
511
|
+
broken.set(hurdleId, {
|
|
512
|
+
title: typeof hurdle?.title === 'string' ? hurdle.title : hurdleId,
|
|
513
|
+
perQuestion: [{ questionNo, lines: [...lines] }],
|
|
514
|
+
});
|
|
515
|
+
} else {
|
|
516
|
+
group.perQuestion.push({ questionNo, lines: [...lines] });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Content criteria: counted for the stderr note, never decisive. A criterion
|
|
521
|
+
// "carries a hurdle" per the registry (K5/K6); everything else is content.
|
|
522
|
+
for (const f of findings) {
|
|
523
|
+
if (f.severity !== 'fail') continue;
|
|
524
|
+
if (criteria[f.criterion]?.hurdle) continue;
|
|
525
|
+
softByCriterion.set(f.criterion, (softByCriterion.get(f.criterion) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (skipped > 0) {
|
|
530
|
+
notes.push(`${skipped} Frage(n) nicht auswertbar — unverändert durchgelassen.`);
|
|
531
|
+
}
|
|
532
|
+
if (softByCriterion.size > 0) {
|
|
533
|
+
const summary = [...softByCriterion.entries()]
|
|
534
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
535
|
+
.map(([id, n]) => `${id}×${n}`)
|
|
536
|
+
.join(', ');
|
|
537
|
+
notes.push(
|
|
538
|
+
`Inhaltliche Befunde (${summary}) — NICHT blockiert. Gemessene Falsch-Positiv-Rate ` +
|
|
539
|
+
'14–25 %; nur die harten Grenzen H1/H2 blockieren.',
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Built ONCE, here, from the same maps the verdict itself is derived from.
|
|
544
|
+
// Re-deriving these counts at the emit site would be a second implementation
|
|
545
|
+
// of the judgement, free to drift from the one that actually decided.
|
|
546
|
+
//
|
|
547
|
+
// `soft` is the load-bearing field, not `hurdles`. The eight content criteria
|
|
548
|
+
// K1-K8 have a measured false-positive rate of 14-25 % and therefore do NOT
|
|
549
|
+
// block (module head) — but that rate was measured on TEMPLATES, and nothing
|
|
550
|
+
// has ever measured it on live runtime questions. `soft` is what makes that
|
|
551
|
+
// measurable: a criterion that fires on almost every real question is a broken
|
|
552
|
+
// instrument by HR-101 and must be re-aimed, not promoted to a hurdle.
|
|
553
|
+
const telemetry = {
|
|
554
|
+
questions: toolInput.questions.length,
|
|
555
|
+
skipped,
|
|
556
|
+
hurdles: [...broken.keys()],
|
|
557
|
+
soft: Object.fromEntries([...softByCriterion.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
if (broken.size === 0) return allow(telemetry);
|
|
561
|
+
|
|
562
|
+
const sections = [];
|
|
563
|
+
let used = 0;
|
|
564
|
+
let omitted = 0;
|
|
565
|
+
// ZWEI Durchgänge: erst jede Gruppe mit mindestens einer Zeile bedienen, dann
|
|
566
|
+
// den Rest auffüllen.
|
|
567
|
+
//
|
|
568
|
+
// Vorher lief das in EINEM Durchgang, und die erste Gruppe durfte das ganze
|
|
569
|
+
// Budget verbrauchen. Gemessen 2026-08-22 mit 4 Fragen × 5 Optionen (alles
|
|
570
|
+
// innerhalb der Protokollgrenzen): von 12 gezeigten H2-Zeilen benannten 10 die
|
|
571
|
+
// Beschreibungslänge — ein Befund, der laut Registry gar keine eigene Hürde
|
|
572
|
+
// trägt — und genau EINE den echten Optionszahl-Bruch, den von Frage 1. Die
|
|
573
|
+
// Brüche der Fragen 2, 3 und 4 landeten vollständig im verworfenen Rest.
|
|
574
|
+
//
|
|
575
|
+
// Das Modell erfuhr also, dass Frage 1 zu viele Optionen hat, und nichts
|
|
576
|
+
// darüber, dass drei weitere denselben Bruch tragen: zweiter Anlauf, zweite
|
|
577
|
+
// Ablehnung, und der Operator sieht keine der beiden Fragen.
|
|
578
|
+
//
|
|
579
|
+
// Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
|
|
580
|
+
const groups = [...broken.entries()];
|
|
581
|
+
// Reihum über FRAGEN, nicht der Reihe nach über Zeilen: erst bekommt jede
|
|
582
|
+
// Frage jeder Gruppe ihre erste Zeile, dann die zweite, und so fort bis das
|
|
583
|
+
// Budget alle ist. Damit ist garantiert, dass jede Frage mit gerissener Hürde
|
|
584
|
+
// benannt wird, solange überhaupt Platz für sie da ist.
|
|
585
|
+
const taken = new Map();
|
|
586
|
+
for (const [id, group] of groups) taken.set(id, group.perQuestion.map(() => 0));
|
|
587
|
+
const depth = Math.max(0, ...groups.map(([, g]) => Math.max(0, ...g.perQuestion.map((q) => q.lines.length))));
|
|
588
|
+
outer: for (let round = 0; round < depth; round += 1) {
|
|
589
|
+
for (const [id, group] of groups) {
|
|
590
|
+
const counts = taken.get(id);
|
|
591
|
+
for (let qi = 0; qi < group.perQuestion.length; qi += 1) {
|
|
592
|
+
if (used >= MAX_FINDING_LINES) break outer;
|
|
593
|
+
if (group.perQuestion[qi].lines.length <= round) continue;
|
|
594
|
+
counts[qi] += 1;
|
|
595
|
+
used += 1;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const [hurdleId, group] of groups) {
|
|
600
|
+
const counts = taken.get(hurdleId);
|
|
601
|
+
const shown = [];
|
|
602
|
+
group.perQuestion.forEach((q, qi) => {
|
|
603
|
+
shown.push(...q.lines.slice(0, counts[qi]));
|
|
604
|
+
omitted += q.lines.length - counts[qi];
|
|
605
|
+
});
|
|
606
|
+
sections.push(`${hurdleId} — ${group.title}\n${shown.join('\n')}`);
|
|
607
|
+
}
|
|
608
|
+
const tail = omitted > 0 ? `\n(+${omitted} weitere Befund(e) nicht gezeigt)` : '';
|
|
609
|
+
|
|
610
|
+
// The FIRST LINE names the concrete break, because `emitDeny` derives the
|
|
611
|
+
// operator-visible headline from it. A general preamble first would put a
|
|
612
|
+
// sentence that is identical for every deny in front of the human, and the
|
|
613
|
+
// one thing they need — WHICH limit broke — past the 200-char clip.
|
|
614
|
+
const reason =
|
|
615
|
+
`AskUserQuestion blockiert: harte Grenze ${[...broken.keys()].join(' + ')} gerissen — ` +
|
|
616
|
+
'so erreicht die Frage den Operator nicht.\n\n' +
|
|
617
|
+
'Das ist keine Stilfrage: das Tool schneidet eine zu lange Kopfzeile selbst ab, und ' +
|
|
618
|
+
'mehr als vier Optionen kann niemand gegeneinander abwägen.\n\n' +
|
|
619
|
+
`${sections.join('\n\n')}${tail}`;
|
|
620
|
+
|
|
621
|
+
const suggestion =
|
|
622
|
+
'Formuliere die Frage neu und stell sie noch einmal — dieser Hook kürzt nicht selbst, ' +
|
|
623
|
+
'weil jedes automatische Kürzen oder Umsortieren Bedeutung verlöre, ohne dass der ' +
|
|
624
|
+
'Operator es sehen könnte. Inhaltliche Kriterien (Grund/Preis/Folge je Option, Jargon, ' +
|
|
625
|
+
'„geh selbst nachsehen") werden hier NICHT geprüft und sind kein Grund für diese Sperre.';
|
|
626
|
+
|
|
627
|
+
return { action: 'deny', reason, suggestion, notes, telemetry };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Entry point — exactly ONE terminal emit
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Write diagnostics to stderr. Under exit 0 this channel is a DEBUG LOG ONLY —
|
|
636
|
+
* it never reaches the operator, so nothing may depend on it being read.
|
|
637
|
+
* Wrapped because stderr can be closed, and a throw here would unwind into the
|
|
638
|
+
* fail-open catch below and cost the diagnostic AND the reason for it.
|
|
639
|
+
*
|
|
640
|
+
* @param {string[]} notes
|
|
641
|
+
* @returns {void}
|
|
642
|
+
*/
|
|
643
|
+
function writeNotes(notes) {
|
|
644
|
+
if (!Array.isArray(notes) || notes.length === 0) return;
|
|
645
|
+
try {
|
|
646
|
+
process.stderr.write(`⚠ ${HOOK_NAME}: ${notes.join(' ')}\n`);
|
|
647
|
+
} catch { /* stderr may be closed; the decision below is what matters */ }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Append one telemetry line, then get out of the way.
|
|
652
|
+
*
|
|
653
|
+
* Awaited by every caller BEFORE the terminal emit — `emitAllow`/`emitDeny` call
|
|
654
|
+
* `process.exit()` and never return, and node discards a pending async write at
|
|
655
|
+
* exit (the same class as `.claude/rules/` § "console.log + process.exit() drops
|
|
656
|
+
* stdout above the pipe buffer"). Awaiting is what makes the record real.
|
|
657
|
+
*
|
|
658
|
+
* Swallows EVERYTHING, including a missing `emitEvent` binding: a telemetry
|
|
659
|
+
* failure that turned a deny into an allow would be strictly worse than no
|
|
660
|
+
* telemetry at all — the guard's whole purpose would be traded for its
|
|
661
|
+
* measurement.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} verb terminal segment of `orchestrator.auq_clarity.<verb>`
|
|
664
|
+
* @param {object} payload shapes and counts only — never question text
|
|
665
|
+
* @param {object|null} input the PreToolUse payload, for session attribution
|
|
666
|
+
* @returns {Promise<void>}
|
|
667
|
+
*/
|
|
668
|
+
async function logDecision(verb, payload, input) {
|
|
669
|
+
try {
|
|
670
|
+
const sessionId = typeof input?.session_id === 'string' && input.session_id
|
|
671
|
+
? input.session_id
|
|
672
|
+
: (typeof input?.parent_session_id === 'string' && input.parent_session_id
|
|
673
|
+
? input.parent_session_id
|
|
674
|
+
: null);
|
|
675
|
+
await emitEvent(`orchestrator.auq_clarity.${verb}`, {
|
|
676
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
677
|
+
...payload,
|
|
678
|
+
});
|
|
679
|
+
} catch {
|
|
680
|
+
// Best-effort — telemetry must never block or alter the guard decision.
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function main() {
|
|
685
|
+
let input;
|
|
686
|
+
try {
|
|
687
|
+
input = await readStdin();
|
|
688
|
+
} catch (err) {
|
|
689
|
+
// Malformed / oversized / slow stdin is a harness quirk, not a bad question.
|
|
690
|
+
// Denying here would destroy every question on a parse error.
|
|
691
|
+
writeNotes([`stdin unlesbar (${String(err?.message ?? err).split('\n')[0]}) — durchgelassen.`]);
|
|
692
|
+
// Logged, because this is a question that reached the operator UNCHECKED.
|
|
693
|
+
// Silent fail-open is the state this whole guard exists to make visible.
|
|
694
|
+
await logDecision('fail_open', { cause: 'stdin-unreadable' }, null);
|
|
695
|
+
return emitAllow();
|
|
696
|
+
}
|
|
697
|
+
if (!input) return emitAllow();
|
|
698
|
+
|
|
699
|
+
// Cheap pre-check: the matcher in hooks.json is not the only line of defence.
|
|
700
|
+
// Deliberately UNLOGGED: on a correct `AskUserQuestion` matcher this branch is
|
|
701
|
+
// unreachable, and logging every foreign tool call would bury the AUQ signal
|
|
702
|
+
// under traffic this hook has no opinion about.
|
|
703
|
+
if (input.tool_name !== AUQ_TOOL) return emitAllow();
|
|
704
|
+
|
|
705
|
+
const verdict = decide(input, { schema: schemaMod, clarity: clarityMod });
|
|
706
|
+
writeNotes(verdict.notes);
|
|
707
|
+
|
|
708
|
+
// The allow event is not bookkeeping — it is the ONLY proof the hook fires at
|
|
709
|
+
// all. Under ADR-0011 allow and deny share exit code 0 and an allow writes no
|
|
710
|
+
// stdout, so "the matcher never delivered" and "delivered, question was clean"
|
|
711
|
+
// are indistinguishable from outside this process without it.
|
|
712
|
+
if (verdict.telemetry) {
|
|
713
|
+
await logDecision(verdict.action === 'deny' ? 'denied' : 'allowed', verdict.telemetry, input);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
|
|
717
|
+
return emitAllow();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// Self-execution guard (§ Import safety).
|
|
722
|
+
//
|
|
723
|
+
// Without it, an `import` of this module runs `main()`, blocks 5 s on stdin and
|
|
724
|
+
// terminates the IMPORTING process with `exit 0` — which under ADR-0011 is
|
|
725
|
+
// itself an ALLOW. `process.argv[1]` carries the path as passed (symlink-bearing
|
|
726
|
+
// under a symlinked plugin install) while `import.meta.url` is realpath-resolved
|
|
727
|
+
// by node's default loader, so BOTH sides are realpath'd.
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
function invokedAsScript() {
|
|
730
|
+
const entry = process.argv[1];
|
|
731
|
+
if (!entry) return false;
|
|
732
|
+
const self = fileURLToPath(import.meta.url);
|
|
733
|
+
try {
|
|
734
|
+
return realpathSync(entry) === realpathSync(self);
|
|
735
|
+
} catch {
|
|
736
|
+
return entry === self;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (invokedAsScript()) {
|
|
741
|
+
// Silent no-op when disabled via profile/env (#211).
|
|
742
|
+
if (!shouldRunHook(HOOK_NAME)) process.exit(0);
|
|
743
|
+
|
|
744
|
+
// -------------------------------------------------------------------------
|
|
745
|
+
// TWO distinct failure classes, two distinct handlers — do NOT merge them:
|
|
746
|
+
//
|
|
747
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Exit 0
|
|
748
|
+
// (fail-OPEN — a broken module must not cost the operator's question, and
|
|
749
|
+
// `emitAllow` itself may be the symbol that failed to load) but SAY SO:
|
|
750
|
+
// GUARD INACTIVE.
|
|
751
|
+
// 2. RUNTIME failure inside `main()`: the guard armed and then tripped. Also
|
|
752
|
+
// fail-open, for the asymmetry in § Fail direction — a wrongly-denied
|
|
753
|
+
// question is never asked, while an unchecked one is merely worse worded.
|
|
754
|
+
// -------------------------------------------------------------------------
|
|
755
|
+
try {
|
|
756
|
+
await bootstrap();
|
|
757
|
+
} catch (loadError) {
|
|
758
|
+
try {
|
|
759
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
760
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
761
|
+
} catch {
|
|
762
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
763
|
+
// repeated noise beats a silent disarm.
|
|
764
|
+
process.stderr.write(
|
|
765
|
+
`🚨 ${HOOK_NAME}: GUARD INACTIVE — module load failed ` +
|
|
766
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
767
|
+
'Runtime AskUserQuestion clarity checking is OFF. See issue #992.\n',
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
main().catch(async (e) => {
|
|
774
|
+
try {
|
|
775
|
+
process.stderr.write(
|
|
776
|
+
`⚠ ${HOOK_NAME}: internal hook error — question ALLOWED unchecked ` +
|
|
777
|
+
`(${String(e?.message ?? e).split('\n')[0]})\n`,
|
|
778
|
+
);
|
|
779
|
+
} catch { /* stderr may be closed; the allow below is the decision */ }
|
|
780
|
+
// stderr is a debug channel nothing is obliged to read (§ writeNotes), so the
|
|
781
|
+
// durable record of "a question got through unchecked" has to go somewhere a
|
|
782
|
+
// later audit can find it. `logDecision` swallows its own failures, so this
|
|
783
|
+
// await cannot keep the allow below from happening.
|
|
784
|
+
await logDecision('fail_open', { cause: 'internal-error' }, null);
|
|
785
|
+
emitAllow();
|
|
786
|
+
});
|
|
787
|
+
}
|