session-orchestrator 4.1.0 → 5.0.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/.agents/skills/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* post-bash-issue-budget-refund.mjs — PostToolUseFailure hook: gives a budget
|
|
4
|
+
* slot back when the `gh|glab issue create` it was booked for did NOT run (#1347).
|
|
5
|
+
*
|
|
6
|
+
* Compensating half of `hooks/pre-bash-issue-budget.mjs`. That hook charges in
|
|
7
|
+
* PreToolUse — BEFORE the command runs — which is the only place a QUANTITY gate
|
|
8
|
+
* can block. The cost is that a create which fails (network, rejected label,
|
|
9
|
+
* expired auth) has already spent its slot, so the retry spends a second one:
|
|
10
|
+
* the same issue consumed two of `issue-budget.max-per-session`, and under
|
|
11
|
+
* `mode: strict` that pushes a legitimate issue into overflow parking.
|
|
12
|
+
*
|
|
13
|
+
* Decision flow (mirrors the pre-hook's gate numbering where it applies):
|
|
14
|
+
* G1 tool filter — only Bash.
|
|
15
|
+
* G2 command is a non-empty string.
|
|
16
|
+
* G2b FAILURE filter — a failure FIELD is required: a non-zero `exit_code`, a
|
|
17
|
+
* non-empty `error`, or `is_error === true` (top level or inside
|
|
18
|
+
* `tool_response`). The EVENT NAME is deliberately NOT a signal — see
|
|
19
|
+
* {@link isFailure}.
|
|
20
|
+
* G3 matcher — the SAME `findIssueCreateStatements` the pre-hook charges with,
|
|
21
|
+
* so the two can never drift on what counts as an issue-create call. One
|
|
22
|
+
* refund per issue-create STATEMENT, which is exactly the unit G6 charged.
|
|
23
|
+
* G3b ATTRIBUTION — one exit code covers the whole Bash call, so it is evidence
|
|
24
|
+
* about the create only when the create statements ARE the whole command
|
|
25
|
+
* (`statementsCoverWholeCommand`). Measured 2026-09-13: `glab issue create
|
|
26
|
+
* --title X && false` files the issue, exits 1, and the first cut of this
|
|
27
|
+
* hook refunded a slot for it. Anything else → no-op,
|
|
28
|
+
* `chain-not-attributable`. EVALUATED AFTER G4 in the code: G4 is the
|
|
29
|
+
* cheaper and more absolute gate, and a repo with the budget off must not
|
|
30
|
+
* report a chain verdict it has no stake in.
|
|
31
|
+
* G4 config — `mode: off` → nothing (there was no charge to give back), and
|
|
32
|
+
* that includes the telemetry: EVERY branch that can emit reads the config
|
|
33
|
+
* first and returns on `off` (see {@link loadBudgetContext}).
|
|
34
|
+
* G5 refund — `refundBooking` per statement, honoured ONLY against a statement
|
|
35
|
+
* present in the session's `charged[]` ledger. A pre-execution failure
|
|
36
|
+
* (parked at the cap and denied, `exit_code: null`) therefore refunds
|
|
37
|
+
* nothing WITHOUT a heuristic here: those statements were never charged.
|
|
38
|
+
* Exemption unit and the never-below-zero / never-touch-`overflow[]`
|
|
39
|
+
* invariants live in the shared core (`scripts/lib/issue-budget.mjs`).
|
|
40
|
+
*
|
|
41
|
+
* Fail-safe posture: every path exits 0 and emits NOTHING on stdout. This hook
|
|
42
|
+
* runs after the tool has already run; it has no decision to make and must not
|
|
43
|
+
* alter the tool result. Internal errors are swallowed in main().catch.
|
|
44
|
+
*
|
|
45
|
+
* TELEMETRY (#1353). Every decision branch that concerns an issue-create command
|
|
46
|
+
* also emits ONE `orchestrator.issue_budget.refunded` record — see
|
|
47
|
+
* {@link emitRefundDecision}. Before that, a refund left only a stderr line,
|
|
48
|
+
* which under exit 0 reaches the debug log alone: refunds were uncountable, so
|
|
49
|
+
* "how often does this fire, and for which reason" was unfalsifiable
|
|
50
|
+
* (`.claude/rules/host-resources.md` HR-105). The no-op reasons are emitted too —
|
|
51
|
+
* without them the census has a numerator and no denominator. A repo with
|
|
52
|
+
* `issue-budget.mode: off` contributes NO record on any branch: it never charged,
|
|
53
|
+
* so its decisions are not part of the population the census describes, and
|
|
54
|
+
* counting them would invert exactly the numerator/denominator argument above.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { readStdin } from '../scripts/lib/io.mjs';
|
|
58
|
+
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
59
|
+
import { readJson } from '../scripts/lib/common.mjs';
|
|
60
|
+
import {
|
|
61
|
+
findIssueCreateStatements,
|
|
62
|
+
statementsCoverWholeCommand,
|
|
63
|
+
} from './_lib/vcs-create-matcher.mjs';
|
|
64
|
+
import {
|
|
65
|
+
loadIssueBudgetConfig,
|
|
66
|
+
resolveIssueBudgetSessionId,
|
|
67
|
+
refundBooking,
|
|
68
|
+
} from '../scripts/lib/issue-budget.mjs';
|
|
69
|
+
|
|
70
|
+
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
71
|
+
import { existsSync } from 'node:fs';
|
|
72
|
+
import path from 'node:path';
|
|
73
|
+
|
|
74
|
+
// Opt-out per session via SO_DISABLED_HOOKS=post-bash-issue-budget-refund; the
|
|
75
|
+
// "minimal"/"off" profiles disable it like every other non-core hook.
|
|
76
|
+
if (!shouldRunHook('post-bash-issue-budget-refund')) process.exit(0);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Helpers
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the accounting session key exactly as the charging hook does — the
|
|
84
|
+
* refund has to land in the SAME per-session counter file the charge wrote, so
|
|
85
|
+
* this resolution order (stdin id > `CLAUDE_CODE_SESSION_ID`, then the
|
|
86
|
+
* `current-session.json` semantic-id bridge) is a contract, not a preference.
|
|
87
|
+
*
|
|
88
|
+
* @param {object|null} input
|
|
89
|
+
* @param {string|null} projectDir
|
|
90
|
+
* @returns {Promise<string|null>}
|
|
91
|
+
*/
|
|
92
|
+
async function resolveSessionId(input, projectDir) {
|
|
93
|
+
const stdinRawId = input?.session_id ?? input?.sessionId ?? null;
|
|
94
|
+
const nativeRawId =
|
|
95
|
+
typeof stdinRawId === 'string' && stdinRawId.length > 0
|
|
96
|
+
? stdinRawId
|
|
97
|
+
: (process.env.CLAUDE_CODE_SESSION_ID ?? null);
|
|
98
|
+
if (typeof nativeRawId !== 'string' || nativeRawId.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
let currentSession = null;
|
|
101
|
+
if (projectDir) {
|
|
102
|
+
const persisted = path.join(projectDir, '.orchestrator', 'current-session.json');
|
|
103
|
+
if (existsSync(persisted)) {
|
|
104
|
+
try {
|
|
105
|
+
currentSession = await readJson(persisted);
|
|
106
|
+
} catch {
|
|
107
|
+
// Malformed or unreadable records conservatively retain the raw key.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return resolveIssueBudgetSessionId(nativeRawId, currentSession);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Did the tool call this payload describes FAIL?
|
|
116
|
+
*
|
|
117
|
+
* ONLY a failure FIELD counts. The event name does NOT: `PostToolUseFailure`
|
|
118
|
+
* also carries PRE-EXECUTION failures (measured: `corrective_context` entries
|
|
119
|
+
* with `exit_code: null` for calls that never ran), so "the event fired" is not
|
|
120
|
+
* evidence that the create ran and failed. The sibling
|
|
121
|
+
* `hooks/post-tool-failure-corrective-context.mjs` reads only `exit_code` /
|
|
122
|
+
* `error` for the same reason.
|
|
123
|
+
*
|
|
124
|
+
* `is_error` is read at BOTH levels. Top level is not speculation: the Cursor
|
|
125
|
+
* bridge builds its canonical payload with `is_error: eventObject.is_error ??
|
|
126
|
+
* eventObject.isError` at TOP level and forwards no `exit_code` and no `error`
|
|
127
|
+
* key at all (`scripts/lib/cursor-hook-bridge.mjs`
|
|
128
|
+
* `normalizeCursorHookPayload`) — so for a Cursor-bridged `postToolUseFailure`
|
|
129
|
+
* the top-level flag is the ONLY failure field that can arrive.
|
|
130
|
+
*
|
|
131
|
+
* A payload whose event name claims a failure while carrying no failure field is
|
|
132
|
+
* a deliberate but LOUD no-op (see the WARN in main()). Silence there is the
|
|
133
|
+
* "built but never switched on" class this hook was caught in.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} input
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
function isFailure(input) {
|
|
139
|
+
if (typeof input?.exit_code === 'number' && input.exit_code !== 0) return true;
|
|
140
|
+
if (typeof input?.error === 'string' && input.error.trim().length > 0) return true;
|
|
141
|
+
if (input?.is_error === true) return true;
|
|
142
|
+
const response = input?.tool_response;
|
|
143
|
+
if (response && typeof response === 'object' && response.is_error === true) return true;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Does this payload CLAIM a failure by event name? Used ONLY to decide whether a
|
|
149
|
+
* missing failure field deserves a WARN — an unclaimed success needs none.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} input
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function claimsFailure(input) {
|
|
155
|
+
const event = input?.hook_event_name ?? input?.hookEventName ?? null;
|
|
156
|
+
return event === 'PostToolUseFailure' || input?.cursor_event_name === 'postToolUseFailure';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The harness's id for THIS tool call, when it publishes one — the first half of
|
|
161
|
+
* a charge record's identity (`bookingId` in the shared core). Absent, the core
|
|
162
|
+
* matches the deterministic session+command+index key instead. Several spellings
|
|
163
|
+
* are accepted because the payload key is the harness's to choose; this resolver
|
|
164
|
+
* is kept identical to the pre-hook's copy, since the two must read the same id
|
|
165
|
+
* out of the same payload or the refund cannot find the charge.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} input
|
|
168
|
+
* @returns {string|null}
|
|
169
|
+
*/
|
|
170
|
+
function resolveToolCallId(input) {
|
|
171
|
+
for (const value of [input?.tool_use_id, input?.toolUseId, input?.tool_call_id, input?.tool_id]) {
|
|
172
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the repo root AND its issue-budget config together — the pair every
|
|
179
|
+
* emitting branch needs, and the reason G4 can be honoured before the FIRST emit
|
|
180
|
+
* rather than only before the refund (#1353 fix-pass).
|
|
181
|
+
*
|
|
182
|
+
* Deliberately NOT hoisted to the top of main(): the config read is a file read,
|
|
183
|
+
* and G1/G2 plus "failed command creates no issue" must stay allocation-free.
|
|
184
|
+
* Every caller sits behind a matcher hit, so an ordinary failing Bash call still
|
|
185
|
+
* pays nothing.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{ projectDir: string, config: ReturnType<typeof loadIssueBudgetConfig> }}
|
|
188
|
+
*/
|
|
189
|
+
function loadBudgetContext() {
|
|
190
|
+
const projectDir = resolveProjectDir() || process.cwd();
|
|
191
|
+
return { projectDir, config: loadIssueBudgetConfig(projectDir) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Event name for the refund decision (#1353). One record per decision, so a
|
|
196
|
+
* census over N sessions can group by `reason` and see the whole population.
|
|
197
|
+
*/
|
|
198
|
+
const ISSUE_BUDGET_REFUNDED_EVENT = 'orchestrator.issue_budget.refunded';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Emit ONE `orchestrator.issue_budget.refunded` record for this invocation.
|
|
202
|
+
*
|
|
203
|
+
* `reason` is a CLOSED enum, mapped onto the hook's branches:
|
|
204
|
+
* `no-signal` — G2b: the payload carries no failure FIELD, so
|
|
205
|
+
* there is no evidence the create ran and failed.
|
|
206
|
+
* `chain-not-attributable` — G3b: the failed command mixes the create with
|
|
207
|
+
* other statements, so the exit code judges neither.
|
|
208
|
+
* `refunded` — G5: at least one charge record was honoured.
|
|
209
|
+
* `not-charged` — G5: no charge record matched (parked at the cap,
|
|
210
|
+
* re-delivered failure, or an identity-less call
|
|
211
|
+
* that was never persisted → `no-session`).
|
|
212
|
+
* The fifth documented reason, `counter-at-zero`, has NO branch here: a matched
|
|
213
|
+
* record whose counter is already 0 is absorbed inside `refundBooking`'s
|
|
214
|
+
* never-below-zero guard and returns `refunded` like any other match, and an
|
|
215
|
+
* UNmatched record returns `noop`/`not-charged`. Surfacing it would need a new
|
|
216
|
+
* field on the shared core's verdict (`scripts/lib/issue-budget.mjs`), so it is
|
|
217
|
+
* reported rather than faked from a value this hook cannot observe.
|
|
218
|
+
*
|
|
219
|
+
* PRIVACY: the payload carries NO command text, issue title or path. This record
|
|
220
|
+
* travels verbatim over the optional Clank webhook with no redaction (same rule
|
|
221
|
+
* as `orchestrator.issue_budget.reconciled`), where a `glab issue create --title
|
|
222
|
+
* …` string or an absolute ledger path is owner data the receiver has no use for.
|
|
223
|
+
*
|
|
224
|
+
* Awaited AND caught: telemetry added to a hook silently disarms it unless both
|
|
225
|
+
* hold — an unawaited promise loses the write when the process exits, and a
|
|
226
|
+
* throwing emit (an unwritable ledger, a schema rejection) would otherwise reach
|
|
227
|
+
* `main().catch` and turn a completed refund into a reported internal error.
|
|
228
|
+
* `events.mjs` is imported LAZILY so the pass-through paths (G1/G2, the
|
|
229
|
+
* overwhelming majority of Bash calls) never pay its module-load cost.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} repoRoot
|
|
232
|
+
* @param {'no-signal'|'chain-not-attributable'|'refunded'|'not-charged'} reason
|
|
233
|
+
* @param {number} statementCount — issue-create statements the matcher found.
|
|
234
|
+
* @param {'count'|'exempt'|null} unit — which counter was given back, read off
|
|
235
|
+
* the honoured charge records (`exempt` only when EVERY refund was an exempt
|
|
236
|
+
* one); `null` whenever nothing was refunded.
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async function emitRefundDecision(repoRoot, reason, statementCount, unit) {
|
|
240
|
+
try {
|
|
241
|
+
const { emitEvent, sessionAttribution } = await import('../scripts/lib/events.mjs');
|
|
242
|
+
await emitEvent(
|
|
243
|
+
ISSUE_BUDGET_REFUNDED_EVENT,
|
|
244
|
+
{
|
|
245
|
+
reason,
|
|
246
|
+
unit,
|
|
247
|
+
statement_count: statementCount,
|
|
248
|
+
...sessionAttribution(repoRoot),
|
|
249
|
+
},
|
|
250
|
+
{ repoRoot },
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
// Best-effort telemetry — never the reason a refund reports failure.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Main
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
async function main() {
|
|
262
|
+
const input = await readStdin();
|
|
263
|
+
if (!input) return;
|
|
264
|
+
|
|
265
|
+
// G1 — only Bash is gated (the pre-hook charges only Bash).
|
|
266
|
+
if (input.tool_name !== 'Bash') return;
|
|
267
|
+
|
|
268
|
+
// G2 — command must be a non-empty string.
|
|
269
|
+
const command = input?.tool_input?.command;
|
|
270
|
+
if (typeof command !== 'string' || command.length === 0) return;
|
|
271
|
+
|
|
272
|
+
// G2b — only a FAILED invocation is compensated, and only on the evidence of a
|
|
273
|
+
// failure FIELD. A payload claiming failure by event name while carrying none
|
|
274
|
+
// is reported rather than silently dropped.
|
|
275
|
+
if (!isFailure(input)) {
|
|
276
|
+
if (claimsFailure(input)) {
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
'⚠ post-bash-issue-budget-refund: failure event carried no failure field ' +
|
|
279
|
+
'(exit_code / error / is_error) — nothing refunded. On the Cursor bridge only ' +
|
|
280
|
+
'top-level `is_error` is forwarded; without it no refund is possible.\n',
|
|
281
|
+
);
|
|
282
|
+
// The matcher runs here ONLY to scope the record: a `no-signal` line for
|
|
283
|
+
// every failing Bash call would drown the census in commands the budget
|
|
284
|
+
// never charged. The hook is registered on PostToolUseFailure alone
|
|
285
|
+
// (hooks/hooks.json), so this is not a hot path for ordinary tool calls.
|
|
286
|
+
const claimed = findIssueCreateStatements(command);
|
|
287
|
+
if (claimed.length > 0) {
|
|
288
|
+
// G4 ahead of the emit: a repo with the budget OFF was never charged, so
|
|
289
|
+
// it is not part of the refund census (docs/events-schema.md).
|
|
290
|
+
const { projectDir, config } = loadBudgetContext();
|
|
291
|
+
if (config.mode !== 'off') {
|
|
292
|
+
await emitRefundDecision(projectDir, 'no-signal', claimed.length, null);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// G3 — shared matcher, per statement: the charge unit is the refund unit.
|
|
300
|
+
const statements = findIssueCreateStatements(command);
|
|
301
|
+
if (statements.length === 0) return;
|
|
302
|
+
|
|
303
|
+
// G4 — config, read BEFORE the first emit below. `off` means nothing was ever
|
|
304
|
+
// charged, so there is neither a slot to give back nor a decision to count.
|
|
305
|
+
const { projectDir, config } = loadBudgetContext();
|
|
306
|
+
if (config.mode === 'off') return;
|
|
307
|
+
|
|
308
|
+
// G3b — one exit code judges the WHOLE call, so it is evidence about the
|
|
309
|
+
// create only when the creates ARE the whole call.
|
|
310
|
+
if (!statementsCoverWholeCommand(command)) {
|
|
311
|
+
process.stderr.write(
|
|
312
|
+
'ℹ post-bash-issue-budget-refund: failed command mixes an issue-create with other ' +
|
|
313
|
+
'statements — chain-not-attributable, no slot refunded.\n',
|
|
314
|
+
);
|
|
315
|
+
await emitRefundDecision(projectDir, 'chain-not-attributable', statements.length, null);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sessionId = await resolveSessionId(input, projectDir);
|
|
320
|
+
const toolCallId = resolveToolCallId(input);
|
|
321
|
+
|
|
322
|
+
// G5 — one refund per statement, each honoured ONLY against the charge record
|
|
323
|
+
// the pre-hook wrote for the same (tool-call id, statement index) pair. The
|
|
324
|
+
// record is REMOVED when honoured, so a re-delivered failure of the same call
|
|
325
|
+
// gives back nothing; a statement that was PARKED instead of charged has no
|
|
326
|
+
// record at all and is `not-charged`.
|
|
327
|
+
const verdicts = statements.map((s, i) =>
|
|
328
|
+
refundBooking({
|
|
329
|
+
repoRoot: projectDir,
|
|
330
|
+
sessionId,
|
|
331
|
+
command: s.text,
|
|
332
|
+
toolCallId,
|
|
333
|
+
statementIndex: i,
|
|
334
|
+
config,
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const refunded = verdicts.filter(
|
|
339
|
+
(v) => v.decision === 'refunded' || v.decision === 'refunded-exempt',
|
|
340
|
+
);
|
|
341
|
+
if (refunded.length > 0) {
|
|
342
|
+
const last = refunded[refunded.length - 1];
|
|
343
|
+
// stderr only: PostToolUse-family stdout would be an envelope Claude reads
|
|
344
|
+
// as a decision, and this hook has none to make.
|
|
345
|
+
process.stderr.write(
|
|
346
|
+
`ℹ post-bash-issue-budget-refund: failed issue-create — ${refunded.length} slot(s) ` +
|
|
347
|
+
`refunded (${last.count}/${last.max})\n`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The unit is read off the honoured records, never re-classified from the
|
|
352
|
+
// command text: `exempt` only when EVERY refund landed on the exempt counter,
|
|
353
|
+
// so a mixed chain (one capped + one exempt create) reports the capped unit —
|
|
354
|
+
// that is the counter the operator's cap is spent from.
|
|
355
|
+
const unit =
|
|
356
|
+
refunded.length === 0
|
|
357
|
+
? null
|
|
358
|
+
: refunded.every((v) => v.decision === 'refunded-exempt')
|
|
359
|
+
? 'exempt'
|
|
360
|
+
: 'count';
|
|
361
|
+
await emitRefundDecision(
|
|
362
|
+
projectDir,
|
|
363
|
+
refunded.length > 0 ? 'refunded' : 'not-charged',
|
|
364
|
+
statements.length,
|
|
365
|
+
unit,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Top-level error handler — fail open, same posture as the sibling hooks.
|
|
370
|
+
main().catch((e) => {
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`⚠ post-bash-issue-budget-refund: internal error — ${e?.message || e}\n`,
|
|
373
|
+
);
|
|
374
|
+
process.exit(0);
|
|
375
|
+
});
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* pre-auq-clarity.mjs — PreToolUse hook on `AskUserQuestion`.
|
|
4
4
|
*
|
|
5
|
-
* Checks the questions this system is about to put in front of the operator,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Checks the questions this system is about to put in front of the operator, at
|
|
6
|
+
* the moment they are asked. It BLOCKS exactly one thing — an option count or
|
|
7
|
+
* recommendation placement the operator cannot weigh (H2, `BLOCKING_HURDLES`);
|
|
8
|
+
* everything else it finds, including an over-budget header (H1), it reports on
|
|
9
|
+
* stderr and in telemetry and lets through.
|
|
9
10
|
*
|
|
10
11
|
* ## Why this exists beside the template gate
|
|
11
12
|
*
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
*
|
|
37
38
|
* | class | decision | why |
|
|
38
39
|
* |----------------------------------------|----------|-----|
|
|
39
|
-
* | H1 — header over 12 codepoints |
|
|
40
|
+
* | H1 — header over 12 codepoints | ALLOW | reported, never blocked. The "the tool truncates it" premise is refuted: `rdr=12` occurs only in the schema's prose, and 125 over-12 headers were accepted and answered. See `BLOCKING_HURDLES`. |
|
|
40
41
|
* | H2 — options per question / recommendation not first | **DENY** | dropping or reordering options is a meaning decision, never a normalisation |
|
|
41
42
|
* | 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
43
|
* | K2, and every `warn` finding | ALLOW | advisory by construction (`CRITERIA.K2.weight === 0`) |
|
|
@@ -65,10 +66,6 @@
|
|
|
65
66
|
* because every repair the two hurdles admit costs meaning that the operator
|
|
66
67
|
* cannot see going missing:
|
|
67
68
|
*
|
|
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
69
|
* - moving the recommended option to position 1 — the ORDER is content. A
|
|
73
70
|
* description written for position 3 can refer to the options above it;
|
|
74
71
|
* lifting it changes what the operator reads first, silently.
|
|
@@ -190,13 +187,52 @@ export const MAX_LINE_CHARS = 300;
|
|
|
190
187
|
*/
|
|
191
188
|
export const MAX_FINDING_LINES = 16;
|
|
192
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The hurdles this hook is allowed to DENY on. Everything else the scorer flags
|
|
192
|
+
* as a hurdle is reported and let through.
|
|
193
|
+
*
|
|
194
|
+
* H2 is here because its cap is REAL: the shipped Zod schema declares
|
|
195
|
+
* `options: T(e).min(2).max(4)`, so a five-option question is rejected by the
|
|
196
|
+
* tool itself — denying early only replaces one error with a better-worded one.
|
|
197
|
+
*
|
|
198
|
+
* H1 is deliberately NOT here, and the reason is a measurement that refutes the
|
|
199
|
+
* premise it was built on. `schema.mjs` justified the deny with "the tool
|
|
200
|
+
* truncates — the operator never sees the rest". In the shipped bundle
|
|
201
|
+
* (2.1.268) the 12 exists as `rdr=12` and is referenced in exactly ONE place:
|
|
202
|
+
* the schema's `describe()` PROSE. There is no `.max(12)`, and no render or
|
|
203
|
+
* truncate path reads it. Measured against the real transcripts
|
|
204
|
+
* (`~/.claude/projects/**\/*.jsonl`, since 2026-06-01), questions whose header
|
|
205
|
+
* exceeded 12 codepoints:
|
|
206
|
+
*
|
|
207
|
+
* accepted by the tool and answered by the operator : 125
|
|
208
|
+
* tool errors not originating from this hook : 0
|
|
209
|
+
* denied by this hook : 103
|
|
210
|
+
*
|
|
211
|
+
* So the only thing that ever destroyed one of those questions was this hook.
|
|
212
|
+
* Fleet-wide it denied 13.0 % of all calls (146 of 1121, 2026-08 → 2026-09),
|
|
213
|
+
* 72 % of them on H1 — above the ~10 % line at which
|
|
214
|
+
* `.claude/rules/host-resources.md` HR-101 calls a class a broken instrument.
|
|
215
|
+
* And the 12 is unit-mismatched (HR-103): it comes from English chip examples
|
|
216
|
+
* (`Auth method` = 11), while 77 % of the breaks in a German corpus miss it by
|
|
217
|
+
* one or two codepoints (`Sitzungsdauer` = 13).
|
|
218
|
+
*
|
|
219
|
+
* H1 stays a hurdle in `scripts/lib/auq/schema.mjs` on purpose: the TEMPLATE
|
|
220
|
+
* gate (`scripts/auq-audit.mjs`) acts on text in a file, where an author can
|
|
221
|
+
* pick a shorter header at no cost and nothing is destroyed by the refusal.
|
|
222
|
+
*
|
|
223
|
+
* REVISIT TRIGGER: a measurement showing the tool actually truncating a header
|
|
224
|
+
* the operator then answered wrongly. That would move H1 back — as a hurdle
|
|
225
|
+
* with evidence, which it has never had.
|
|
226
|
+
*/
|
|
227
|
+
export const BLOCKING_HURDLES = Object.freeze(['H2']);
|
|
228
|
+
|
|
193
229
|
/**
|
|
194
230
|
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993).
|
|
195
231
|
*/
|
|
196
232
|
const GUARD_CONSEQUENCE = {
|
|
197
233
|
inactive: [
|
|
198
234
|
' Consequence: runtime AskUserQuestion clarity checking is OFF — a question',
|
|
199
|
-
' with
|
|
235
|
+
' with an unweighable option set CAN now reach the',
|
|
200
236
|
' operator unchecked. Template-level checking is unaffected. This is a',
|
|
201
237
|
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
202
238
|
],
|
|
@@ -405,9 +441,9 @@ function renderFinding(f, questionNo) {
|
|
|
405
441
|
/**
|
|
406
442
|
* Decide whether this `AskUserQuestion` call may proceed.
|
|
407
443
|
*
|
|
408
|
-
* Denies if and only if at least one
|
|
409
|
-
* one question. Content criteria
|
|
410
|
-
* action.
|
|
444
|
+
* Denies if and only if at least one BLOCKING hurdle (`BLOCKING_HURDLES` = H2)
|
|
445
|
+
* is broken on at least one question. Content criteria, and every hurdle outside
|
|
446
|
+
* that set, are collected into `notes` and never affect the action.
|
|
411
447
|
*
|
|
412
448
|
* ## Known limitation, with its revisit trigger (BV-004)
|
|
413
449
|
*
|
|
@@ -550,14 +586,30 @@ export function decide(input, lib) {
|
|
|
550
586
|
// has ever measured it on live runtime questions. `soft` is what makes that
|
|
551
587
|
// measurable: a criterion that fires on almost every real question is a broken
|
|
552
588
|
// instrument by HR-101 and must be re-aimed, not promoted to a hurdle.
|
|
589
|
+
// Split BEFORE the telemetry is built, so both halves are recorded from the
|
|
590
|
+
// same maps the verdict is derived from (see BLOCKING_HURDLES).
|
|
591
|
+
const blocking = [...broken.entries()].filter(([id]) => BLOCKING_HURDLES.includes(id));
|
|
592
|
+
const advisory = [...broken.entries()].filter(([id]) => !BLOCKING_HURDLES.includes(id));
|
|
593
|
+
|
|
594
|
+
if (advisory.length > 0) {
|
|
595
|
+
notes.push(
|
|
596
|
+
`Beratende Grenze(n) ${advisory.map(([id]) => id).join(', ')} gerissen — NICHT blockiert. `
|
|
597
|
+
+ 'Die Vorlagenprüfung (scripts/auq-audit.mjs) hält sie weiterhin hart.',
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
553
601
|
const telemetry = {
|
|
554
602
|
questions: toolInput.questions.length,
|
|
555
603
|
skipped,
|
|
604
|
+
// EVERY broken hurdle, blocking or not — the field keeps its historical
|
|
605
|
+
// meaning, so a fleet tally over the whole store stays comparable across the
|
|
606
|
+
// change that made H1 advisory. `blocking` is the subset that decided.
|
|
556
607
|
hurdles: [...broken.keys()],
|
|
608
|
+
blocking: blocking.map(([id]) => id),
|
|
557
609
|
soft: Object.fromEntries([...softByCriterion.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
558
610
|
};
|
|
559
611
|
|
|
560
|
-
if (
|
|
612
|
+
if (blocking.length === 0) return allow(telemetry);
|
|
561
613
|
|
|
562
614
|
const sections = [];
|
|
563
615
|
let used = 0;
|
|
@@ -577,7 +629,7 @@ export function decide(input, lib) {
|
|
|
577
629
|
// Ablehnung, und der Operator sieht keine der beiden Fragen.
|
|
578
630
|
//
|
|
579
631
|
// Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
|
|
580
|
-
const groups =
|
|
632
|
+
const groups = blocking;
|
|
581
633
|
// Reihum über FRAGEN, nicht der Reihe nach über Zeilen: erst bekommt jede
|
|
582
634
|
// Frage jeder Gruppe ihre erste Zeile, dann die zweite, und so fort bis das
|
|
583
635
|
// Budget alle ist. Damit ist garantiert, dass jede Frage mit gerissener Hürde
|
|
@@ -612,10 +664,10 @@ export function decide(input, lib) {
|
|
|
612
664
|
// sentence that is identical for every deny in front of the human, and the
|
|
613
665
|
// one thing they need — WHICH limit broke — past the 200-char clip.
|
|
614
666
|
const reason =
|
|
615
|
-
`AskUserQuestion blockiert: harte Grenze ${
|
|
667
|
+
`AskUserQuestion blockiert: harte Grenze ${groups.map(([id]) => id).join(' + ')} gerissen — ` +
|
|
616
668
|
'so erreicht die Frage den Operator nicht.\n\n' +
|
|
617
|
-
'Das ist keine Stilfrage: das Tool
|
|
618
|
-
'mehr als vier
|
|
669
|
+
'Das ist keine Stilfrage: das Tool selbst nimmt nur zwei bis vier Optionen je Frage an, ' +
|
|
670
|
+
'und mehr als vier kann niemand gegeneinander abwägen.\n\n' +
|
|
619
671
|
`${sections.join('\n\n')}${tail}`;
|
|
620
672
|
|
|
621
673
|
const suggestion =
|