session-orchestrator 3.17.0 → 3.20.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/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
package/scripts/lib/io.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
20
|
-
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, writeSync } from 'node:fs';
|
|
21
21
|
import path, { dirname } from 'node:path';
|
|
22
22
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
23
23
|
|
|
@@ -27,6 +27,12 @@ import { randomBytes, randomUUID } from 'node:crypto';
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Build the deny reason string, optionally appending a suggestion.
|
|
30
|
+
*
|
|
31
|
+
* Multi-line `reason` values are passed through verbatim — callers that need a
|
|
32
|
+
* several-line rationale (e.g. hooks/pre-bash-destructive-guard.mjs) rely on
|
|
33
|
+
* this. `JSON.stringify` later escapes the newlines, so the emitted payload
|
|
34
|
+
* still occupies exactly one stdout line.
|
|
35
|
+
*
|
|
30
36
|
* @param {string} reason
|
|
31
37
|
* @param {string|undefined} suggestion
|
|
32
38
|
* @returns {string}
|
|
@@ -35,6 +41,161 @@ function _formatReason(reason, suggestion) {
|
|
|
35
41
|
return suggestion ? `${reason} — ${suggestion}` : reason;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** Max length of the operator-facing `systemMessage` headline. */
|
|
45
|
+
const DENY_HEADLINE_MAX = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hard ceiling (in characters) for `permissionDecisionReason`.
|
|
49
|
+
*
|
|
50
|
+
* Rationale is empirical, not a round number pulled from the air. Measured
|
|
51
|
+
* worst-case reason lengths across all 12 live `emitDeny` call sites:
|
|
52
|
+
*
|
|
53
|
+
* | call site | measured chars |
|
|
54
|
+
* |----------------------------------------------|----------------|
|
|
55
|
+
* | pre-bash-destructive-guard (worst of 14 rules) | 435 |
|
|
56
|
+
* | pre-bash-templates-first (fixed part) | 348 + command |
|
|
57
|
+
* | pre-bash-issue-budget (formatBlockReason) | 652 |
|
|
58
|
+
* | config-protection (all 6 reasons at once) | 432 |
|
|
59
|
+
* | enforce-scope (LIVE wave-scope, 18 paths) | 1 322 |
|
|
60
|
+
* | enforce-scope (deep wave, 144-path union) | ~9 068 |
|
|
61
|
+
*
|
|
62
|
+
* `enforce-scope` joins `allowedPaths` into BOTH the reason and the suggestion,
|
|
63
|
+
* so its length grows at ~2× the union. A deep session (18 agents) with a wide
|
|
64
|
+
* union is the binding constraint at ~9 k — which is why the ceiling is NOT the
|
|
65
|
+
* 8 000 originally proposed: that would clip a legitimate deep-wave scope
|
|
66
|
+
* violation exactly when the operator most needs the path list. 16 000 sits
|
|
67
|
+
* ~1.8× above that worst case and ~12× above the largest measured live reason,
|
|
68
|
+
* while staying ~4× below the 65 536-byte kernel pipe buffer — so even the clamp
|
|
69
|
+
* ALONE keeps a typical-ASCII envelope inside one buffer.
|
|
70
|
+
*/
|
|
71
|
+
const DENY_REASON_MAX = 16_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hard ceiling (in characters) for the operator-facing `systemMessage` that
|
|
75
|
+
* {@link emitWarn} emits.
|
|
76
|
+
*
|
|
77
|
+
* Same ceiling as {@link DENY_REASON_MAX}, and for the same measured reason:
|
|
78
|
+
* the warn path shares its call sites with the deny path — `enforce-scope` calls
|
|
79
|
+
* `emitDeny(reason, suggestion)` under `strict` and `emitWarn(reason — suggestion)`
|
|
80
|
+
* under `warn`, on the identical text. The binding case is therefore identical
|
|
81
|
+
* too (a deep wave's 144-path allowedPaths union, ~9 068 chars, interpolated into
|
|
82
|
+
* both halves), so a tighter warn ceiling would clip exactly the path list the
|
|
83
|
+
* operator needs. 16 000 stays ~4× below the 65 536-byte kernel pipe buffer.
|
|
84
|
+
*
|
|
85
|
+
* NOT reused as a shared alias by accident: `emitDeny` splits its text across a
|
|
86
|
+
* clipped 200-char headline plus the full `permissionDecisionReason`, whereas
|
|
87
|
+
* `systemMessage` is the warn path's ONLY carrier — this constant is what makes
|
|
88
|
+
* that difference explicit rather than incidental.
|
|
89
|
+
*/
|
|
90
|
+
const WARN_MESSAGE_MAX = 16_000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reason substituted when a caller denies without supplying one.
|
|
94
|
+
*
|
|
95
|
+
* A guard must never fail on its own bookkeeping: throwing here used to land in
|
|
96
|
+
* the fail-open `main().catch(() => emitAllow())` of four hooks
|
|
97
|
+
* (pre-bash-destructive-guard, pre-bash-issue-budget, pre-bash-templates-first,
|
|
98
|
+
* config-protection) and turn a deny into an ALLOW. The programmer-error signal
|
|
99
|
+
* is preserved as a stderr diagnostic instead of a throw.
|
|
100
|
+
*/
|
|
101
|
+
const EMPTY_REASON_FALLBACK =
|
|
102
|
+
'Denied by a session-orchestrator guard that did not supply a reason (guard bug — see stderr). '
|
|
103
|
+
+ 'Blocking rather than allowing: a guard must fail closed.';
|
|
104
|
+
|
|
105
|
+
/** stdout file descriptor. */
|
|
106
|
+
const STDOUT_FD = 1;
|
|
107
|
+
|
|
108
|
+
/** Backoff between EAGAIN retries in the synchronous stdout writer. */
|
|
109
|
+
const STDOUT_EAGAIN_BACKOFF_MS = 1;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Clip `text` to `max` characters, marking the cut with an ellipsis.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @param {number} max
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function _clip(text, max) {
|
|
119
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Derive the short operator-facing headline from a (possibly multi-line) reason.
|
|
124
|
+
* First line only, clipped — the full text stays in `permissionDecisionReason`.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} reason
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function _denyHeadline(reason) {
|
|
130
|
+
return `⛔ ${_clip(reason.split('\n')[0].trim(), DENY_HEADLINE_MAX)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clamp a reason/message to `max` characters, appending a visible marker that
|
|
135
|
+
* names how much was dropped. The marker is budgeted INSIDE the ceiling, so the
|
|
136
|
+
* returned string never exceeds it.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} reason
|
|
139
|
+
* @param {number} [max=DENY_REASON_MAX]
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function _clampReason(reason, max = DENY_REASON_MAX) {
|
|
143
|
+
if (reason.length <= max) return reason;
|
|
144
|
+
const marker = `\n… [truncated: showing ${max} of ${reason.length} characters]`;
|
|
145
|
+
return reason.slice(0, max - marker.length) + marker;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Synchronously write one line (a trailing `\n` is appended) to stdout,
|
|
150
|
+
* looping until every byte is handed to the kernel.
|
|
151
|
+
*
|
|
152
|
+
* ## Why this exists instead of `console.log`
|
|
153
|
+
*
|
|
154
|
+
* `process.stdout` is **asynchronous when it is a pipe on macOS** (Node docs,
|
|
155
|
+
* "process I/O": "Pipes and sockets: … asynchronous on macOS") — exactly the
|
|
156
|
+
* configuration every Claude Code hook runs under. `console.log` therefore only
|
|
157
|
+
* QUEUES the write: whatever does not fit into the 65 536-byte kernel pipe
|
|
158
|
+
* buffer stays in libuv's write queue, and `process.exit()` discards it. The
|
|
159
|
+
* observable result is a truncated, unparseable JSON envelope delivered with
|
|
160
|
+
* exit 0.
|
|
161
|
+
*
|
|
162
|
+
* Before #906 that truncation was harmless: `emitDeny` exited **2**, which
|
|
163
|
+
* blocks regardless of stdout. Once the helper moved to `exit 0` + structured
|
|
164
|
+
* JSON, the same truncation flipped the guard layer from fail-CLOSED to
|
|
165
|
+
* fail-OPEN — a >64 KB reason meant the harness saw no structured output and
|
|
166
|
+
* ALLOWED the tool call. `writeSync` bypasses the libuv queue entirely, so the
|
|
167
|
+
* bytes are in the kernel before `process.exit` runs.
|
|
168
|
+
*
|
|
169
|
+
* EAGAIN is expected when fd 1 is non-blocking and the pipe is momentarily
|
|
170
|
+
* full; the loop backs off and retries rather than dropping the tail. Any other
|
|
171
|
+
* error (EPIPE — reader gone) is reported to the caller, which must then decide
|
|
172
|
+
* how to signal WITHOUT stdout. Never throws.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} line Payload without trailing newline.
|
|
175
|
+
* @returns {{ ok: true, bytesWritten: number } | { ok: false, bytesWritten: number, error: string }}
|
|
176
|
+
*/
|
|
177
|
+
export function writeStdoutLineSync(line) {
|
|
178
|
+
const buf = Buffer.from(`${line}\n`, 'utf8');
|
|
179
|
+
let offset = 0;
|
|
180
|
+
while (offset < buf.length) {
|
|
181
|
+
let written;
|
|
182
|
+
try {
|
|
183
|
+
written = writeSync(STDOUT_FD, buf, offset, buf.length - offset);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const code = err?.code;
|
|
186
|
+
if (code === 'EAGAIN' || code === 'EINTR') {
|
|
187
|
+
// Synchronous backoff: Atomics.wait is the only event-loop-free sleep,
|
|
188
|
+
// and the event loop is precisely what we cannot yield to here.
|
|
189
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STDOUT_EAGAIN_BACKOFF_MS);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
return { ok: false, bytesWritten: offset, error: code ?? String(err) };
|
|
193
|
+
}
|
|
194
|
+
offset += written;
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, bytesWritten: offset };
|
|
197
|
+
}
|
|
198
|
+
|
|
38
199
|
// ---------------------------------------------------------------------------
|
|
39
200
|
// Exports
|
|
40
201
|
// ---------------------------------------------------------------------------
|
|
@@ -124,24 +285,198 @@ export function emitAllow() {
|
|
|
124
285
|
}
|
|
125
286
|
|
|
126
287
|
/**
|
|
127
|
-
* Deny the current hook invocation
|
|
128
|
-
*
|
|
288
|
+
* Deny the current **PreToolUse** hook invocation: emit exactly one JSON object
|
|
289
|
+
* on stdout, then exit **0**.
|
|
290
|
+
*
|
|
291
|
+
* ## The contract (code.claude.com/docs/en/hooks), verbatim
|
|
292
|
+
*
|
|
293
|
+
* > "**Exit 2** means a blocking error. Claude Code **ignores stdout and any
|
|
294
|
+
* > JSON in it**. Instead, stderr text is fed back to Claude as an error
|
|
295
|
+
* > message."
|
|
296
|
+
*
|
|
297
|
+
* > "You must choose one approach per hook, **not both**: either use exit codes
|
|
298
|
+
* > alone for signaling, or exit 0 and print JSON for structured control."
|
|
299
|
+
*
|
|
300
|
+
* > "Unlike other hooks that use a top-level `decision` field, **PreToolUse
|
|
301
|
+
* > returns its decision inside a `hookSpecificOutput` object**" — which
|
|
302
|
+
* > "requires a `hookEventName` field set to the event name."
|
|
303
|
+
*
|
|
304
|
+
* > PreToolUse top-level `decision` / `reason` are deprecated: "Use
|
|
305
|
+
* > `hookSpecificOutput.permissionDecision` and
|
|
306
|
+
* > `hookSpecificOutput.permissionDecisionReason` instead."
|
|
307
|
+
*
|
|
308
|
+
* Until #906 this function emitted stdout JSON **and** `exit 2` — the mixed
|
|
309
|
+
* form the second quote forbids. The block still bit, but the reason was
|
|
310
|
+
* discarded wholesale and the operator saw only `hook error: … No stderr
|
|
311
|
+
* output`, i.e. what looks like a crash. Do not reintroduce either half of that
|
|
312
|
+
* mixed form: `exit 2` here, or a flat top-level `{permissionDecision, reason}`.
|
|
313
|
+
*
|
|
314
|
+
* ## Emitted payload (single stdout line, nothing else on stdout)
|
|
315
|
+
*
|
|
316
|
+
* ```json
|
|
317
|
+
* {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"<reason — suggestion>"},"systemMessage":"⛔ <first line of reason>"}
|
|
318
|
+
* ```
|
|
319
|
+
*
|
|
320
|
+
* `permissionDecisionReason` is fed to **Claude**; the universal `systemMessage`
|
|
321
|
+
* field is what the **operator** sees. Both are emitted on purpose: operator
|
|
322
|
+
* visibility is the entire point of the #906 repair, and a `deny` whose cause is
|
|
323
|
+
* invisible to the human is what pushed wave agents into circumventing the
|
|
324
|
+
* enforcement layer. The headline is deliberately short (first line, clipped) —
|
|
325
|
+
* the long text belongs in `permissionDecisionReason`.
|
|
326
|
+
*
|
|
327
|
+
* Multi-line reasons survive intact: `JSON.stringify` escapes the newlines, so
|
|
328
|
+
* the payload stays exactly one line and consumers that parse stdout line-wise
|
|
329
|
+
* (e.g. `scripts/lib/pi-hook-bridge.mjs`) keep working.
|
|
330
|
+
*
|
|
331
|
+
* ## PRECONDITION — PreToolUse hooks only
|
|
332
|
+
*
|
|
333
|
+
* `hookEventName` is hardcoded to `PreToolUse`. PostToolUse / Stop /
|
|
334
|
+
* SubagentStop signal through a **top-level** `decision` / `reason` pair and
|
|
335
|
+
* MUST NOT be routed through this helper.
|
|
336
|
+
*
|
|
337
|
+
* ## Delivery is part of the contract, not an implementation detail
|
|
338
|
+
*
|
|
339
|
+
* The envelope goes out through {@link writeStdoutLineSync}, never
|
|
340
|
+
* `console.log`. On macOS a piped stdout is asynchronous, so `console.log` +
|
|
341
|
+
* `process.exit(0)` silently drops everything past the 65 536-byte kernel pipe
|
|
342
|
+
* buffer — a truncated envelope reads as "no structured output" and the tool
|
|
343
|
+
* call is ALLOWED. Two independent bounds keep that from happening:
|
|
344
|
+
*
|
|
345
|
+
* 1. `permissionDecisionReason` is clamped to {@link DENY_REASON_MAX}, and the
|
|
346
|
+
* `opts.systemMessage` override to {@link DENY_HEADLINE_MAX}, so no caller
|
|
347
|
+
* — including one that funnels attacker-controlled tool input into the
|
|
348
|
+
* reason, as pre-bash-templates-first does with the raw bash command — can
|
|
349
|
+
* inflate the envelope past the buffer.
|
|
350
|
+
* 2. The write itself is synchronous and loops to completion, so even an
|
|
351
|
+
* envelope that somehow exceeds the buffer still lands in full.
|
|
352
|
+
*
|
|
353
|
+
* Both are load-bearing: the clamp alone would not survive a caller that
|
|
354
|
+
* bypasses it, and the synchronous write alone would ship 200 KB envelopes to
|
|
355
|
+
* the harness. If stdout cannot be written at all (EPIPE — the reader is gone),
|
|
356
|
+
* the helper exits **2**: with no structured channel left, the exit code is the
|
|
357
|
+
* only remaining way to block, and the "never both" rule is not violated
|
|
358
|
+
* because no parseable JSON was delivered.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} reason Human-readable denial reason. May be multi-line. When
|
|
361
|
+
* blank/absent the call still DENIES (with a generic reason plus a stderr
|
|
362
|
+
* diagnostic) — see {@link EMPTY_REASON_FALLBACK} for why this does not
|
|
363
|
+
* throw.
|
|
129
364
|
* @param {string} [suggestion] Optional remediation hint appended after " — ".
|
|
365
|
+
* @param {object} [opts]
|
|
366
|
+
* @param {string} [opts.systemMessage] Override the derived operator headline.
|
|
367
|
+
* Ignored when blank; clipped to one short line.
|
|
130
368
|
* @returns {never}
|
|
131
369
|
*/
|
|
132
|
-
export function emitDeny(reason, suggestion) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
370
|
+
export function emitDeny(reason, suggestion, opts = {}) {
|
|
371
|
+
// Coerce defensively and never throw: a throw inside a deny path unwinds into
|
|
372
|
+
// the fail-open `main().catch(() => emitAllow())` that four hooks install,
|
|
373
|
+
// turning the deny into an ALLOW. A guard must never fail open on its own
|
|
374
|
+
// bookkeeping — so a missing reason degrades to a generic deny plus a loud
|
|
375
|
+
// stderr diagnostic, which keeps the programmer-error signal without the
|
|
376
|
+
// fail-open blast radius.
|
|
377
|
+
const raw = typeof reason === 'string'
|
|
378
|
+
? reason
|
|
379
|
+
: (reason === null || reason === undefined ? '' : String(reason));
|
|
380
|
+
const missingReason = raw.trim() === '';
|
|
381
|
+
if (missingReason) {
|
|
382
|
+
try {
|
|
383
|
+
process.stderr.write(
|
|
384
|
+
'⚠ io.mjs: emitDeny called without a reason — denying with a generic message (guard bug)\n',
|
|
385
|
+
);
|
|
386
|
+
} catch { /* stderr may be closed; the deny below is what matters */ }
|
|
387
|
+
}
|
|
388
|
+
const permissionDecisionReason = _clampReason(
|
|
389
|
+
String(_formatReason(missingReason ? EMPTY_REASON_FALLBACK : raw, suggestion)),
|
|
390
|
+
);
|
|
391
|
+
const override = typeof opts?.systemMessage === 'string' ? opts.systemMessage.trim() : '';
|
|
392
|
+
const line = JSON.stringify({
|
|
393
|
+
hookSpecificOutput: {
|
|
394
|
+
hookEventName: 'PreToolUse',
|
|
395
|
+
permissionDecision: 'deny',
|
|
396
|
+
permissionDecisionReason,
|
|
397
|
+
},
|
|
398
|
+
systemMessage: override !== ''
|
|
399
|
+
? _clip(override, DENY_HEADLINE_MAX)
|
|
400
|
+
: _denyHeadline(permissionDecisionReason),
|
|
401
|
+
});
|
|
402
|
+
const result = writeStdoutLineSync(line);
|
|
403
|
+
// stdout delivered → exit 0 (structured channel). stdout unwritable → exit 2,
|
|
404
|
+
// the only blocking signal left. Never exit 0 on a failed write: that is the
|
|
405
|
+
// fail-open case this whole helper exists to prevent.
|
|
406
|
+
process.exit(result.ok ? 0 : 2);
|
|
136
407
|
}
|
|
137
408
|
|
|
138
409
|
/**
|
|
139
|
-
* Emit
|
|
140
|
-
*
|
|
410
|
+
* Emit an operator-visible warning and exit **0** — "allow, with a notice".
|
|
411
|
+
*
|
|
412
|
+
* ## Why this is not stderr-only (#916)
|
|
413
|
+
*
|
|
414
|
+
* Until #916 this helper wrote only to stderr. Under the exit-0 branch of the
|
|
415
|
+
* hook contract that channel goes nowhere: `docs/plugin-architecture-v3.md`
|
|
416
|
+
* states "stderr | Only for debugging. Not surfaced to the user.", and
|
|
417
|
+
* `skills/hook-development/SKILL.md` puts it plainly — "When the hook exits 0
|
|
418
|
+
* here it is **silent** — no stdout, no stderr". So every `enforcement: warn`
|
|
419
|
+
* scope/command violation was announced to a debug log and to nobody else. A
|
|
420
|
+
* warning that reaches neither the operator nor the model is not a warning.
|
|
421
|
+
*
|
|
422
|
+
* The visible channel is the universal top-level `systemMessage` field — the
|
|
423
|
+
* same one {@link emitDeny} already rides for its ⛔ headline, and the one
|
|
424
|
+
* {@link emitSystemMessage} documents. It is emitted here WITHOUT any
|
|
425
|
+
* `hookSpecificOutput` / `permissionDecision`, which is precisely what keeps
|
|
426
|
+
* warn non-blocking: the harness sees a message but no decision.
|
|
427
|
+
*
|
|
428
|
+
* stderr is retained unchanged for debug-log and CI-capture parity.
|
|
429
|
+
*
|
|
430
|
+
* ## Consumer contract — stdout in the warn path is no longer empty
|
|
431
|
+
*
|
|
432
|
+
* `scripts/lib/pi-hook-bridge.mjs#readHookDecision` scans stdout line-wise and
|
|
433
|
+
* keeps the first line that actually carries a `permissionDecision`; a
|
|
434
|
+
* decision-less JSON line (this one) is skipped, so the Pi lane reports
|
|
435
|
+
* `{decision: null, deny: false, malformed: false}` ⇒ not blocked. Verified
|
|
436
|
+
* empirically, not assumed. Codex does not wire these hooks at all
|
|
437
|
+
* (`hooks/hooks-codex.json` has `"PreToolUse": []`), and `hooks/hooks-cursor.json`
|
|
438
|
+
* is a documentation-only mapping reference with no in-repo executor.
|
|
439
|
+
*
|
|
440
|
+
* ## Delivery: the same two bounds {@link emitDeny} uses, for a sharper reason
|
|
441
|
+
*
|
|
442
|
+
* The write goes through {@link writeStdoutLineSync}, never `console.log`, and
|
|
443
|
+
* the message is clamped to {@link WARN_MESSAGE_MAX}. On macOS a piped stdout is
|
|
444
|
+
* asynchronous, so `console.log` + `process.exit(0)` drops everything past the
|
|
445
|
+
* 65 536-byte kernel pipe buffer — and `enforce-scope` interpolates the whole
|
|
446
|
+
* `allowedPaths` union into its warn text TWICE, so this path genuinely reaches
|
|
447
|
+
* five figures. A truncated line here is worse than a lost warning: the Pi
|
|
448
|
+
* bridge classifies an unparseable `{`-prefixed line as `malformed`, which for
|
|
449
|
+
* PreToolUse fails CLOSED — i.e. pipe truncation would silently convert
|
|
450
|
+
* `enforcement: warn` into a hard block. Both bounds are load-bearing.
|
|
451
|
+
*
|
|
452
|
+
* ## Deliberate asymmetry to emitDeny: an unwritable stdout still exits 0
|
|
453
|
+
*
|
|
454
|
+
* {@link emitDeny} exits 2 when stdout cannot be written, because with no
|
|
455
|
+
* structured channel left the exit code is its only way to block. The warn path
|
|
456
|
+
* inverts that: its decision is "allow", so a failed write must cost the
|
|
457
|
+
* NOTICE, never the permission. Exiting non-zero here would turn a dropped
|
|
458
|
+
* warning into a blocked tool call.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} message Warning text. Emitted on stderr and as the
|
|
461
|
+
* `systemMessage` payload, both prefixed with "⚠ ".
|
|
141
462
|
* @returns {never}
|
|
142
463
|
*/
|
|
143
464
|
export function emitWarn(message) {
|
|
144
|
-
|
|
465
|
+
// Coerce defensively: a throw here would unwind into the callers' top-level
|
|
466
|
+
// `main().catch(() => emitDeny(...))`, turning an allow-with-notice into a DENY.
|
|
467
|
+
const raw = typeof message === 'string'
|
|
468
|
+
? message
|
|
469
|
+
: (message === null || message === undefined ? '' : String(message));
|
|
470
|
+
const text = `⚠ ${_clampReason(raw, WARN_MESSAGE_MAX)}`;
|
|
471
|
+
|
|
472
|
+
// Debug channel — unchanged. Invisible under exit 0, kept for log/CI capture.
|
|
473
|
+
try {
|
|
474
|
+
console.error(text);
|
|
475
|
+
} catch { /* stderr may be closed; the visible channel below is what matters */ }
|
|
476
|
+
|
|
477
|
+
// Visible channel. Return value is deliberately ignored — see the asymmetry
|
|
478
|
+
// note above: a warning that cannot be delivered must not become a block.
|
|
479
|
+
writeStdoutLineSync(JSON.stringify({ systemMessage: text }));
|
|
145
480
|
process.exit(0);
|
|
146
481
|
}
|
|
147
482
|
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* issue-budget.mjs — per-session issue-creation cap: state, exemptions, verdict.
|
|
3
|
+
*
|
|
4
|
+
* WHY: measured on the private instance over four weeks, sessions created 1784
|
|
5
|
+
* issues against 1285 closed (net +499), with a median inter-creation gap of
|
|
6
|
+
* 2.6 s and 62 % of creations inside bursts of >= 5 per minute. The pre-existing
|
|
7
|
+
* `discovery-severity-threshold` / `discovery-confidence-threshold` keys cannot
|
|
8
|
+
* bound that: they filter individual FINDINGS by quality (and the `low` default
|
|
9
|
+
* filters nothing), they are only consulted in skill prose, and the biggest
|
|
10
|
+
* producers — session-end carryover filing, plan issue creation,
|
|
11
|
+
* `scripts/lib/spiral-carryover.mjs` — never read them at all.
|
|
12
|
+
*
|
|
13
|
+
* This module is the single decision point for BOTH producer paths:
|
|
14
|
+
* - shell path: `hooks/pre-bash-issue-budget.mjs` (PreToolUse/Bash)
|
|
15
|
+
* - programmatic path: `scripts/lib/spiral-carryover.mjs` `runCli()`
|
|
16
|
+
*
|
|
17
|
+
* EXEMPTIONS ARE LOAD-BEARING. `skills/session-end/SKILL.md` carries two
|
|
18
|
+
* standing promises the cap must not break:
|
|
19
|
+
* - `:319` — "SPIRAL / FAILED agent carryover → auto-carry candidate …
|
|
20
|
+
* (non-deselectable)"
|
|
21
|
+
* - `:1113` — "ALWAYS create issues for unfinished PLANNED work … nothing
|
|
22
|
+
* planned-but-unfinished is 'remembered' without one."
|
|
23
|
+
* A cap that silently swallowed a SPIRAL carryover would turn a hard promise
|
|
24
|
+
* into a lie, so the carryover class plus `priority::critical` bypass the cap
|
|
25
|
+
* entirely (they are counted for observability, but never blocked).
|
|
26
|
+
*
|
|
27
|
+
* Stdlib only — the hook path must stay cheap enough to run on every Bash call.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { writeJsonAtomicSync } from './io.mjs';
|
|
34
|
+
import { resolveInstructionFile } from './common.mjs';
|
|
35
|
+
import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
36
|
+
|
|
37
|
+
/** Runtime counter file, relative to the repo root. */
|
|
38
|
+
export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Commands whose issue creation MUST NOT be blocked, with the reason recorded
|
|
42
|
+
* for the stderr trace and for the overflow bookkeeping.
|
|
43
|
+
*
|
|
44
|
+
* Each entry is `[regex, reason]`. Both the scoped (`priority::critical`) and
|
|
45
|
+
* the legacy unscoped (`priority:critical`) spellings are accepted: the label
|
|
46
|
+
* migration to scoped `priority::` runs alongside this change and a
|
|
47
|
+
* half-migrated producer must not lose its exemption mid-flight.
|
|
48
|
+
*/
|
|
49
|
+
const EXEMPT_RULES = [
|
|
50
|
+
[/priority::?critical\b/i, 'priority::critical'],
|
|
51
|
+
// `[Carryover] [SPIRAL] …` / `[Carryover] [FAILED] …` — the exact title
|
|
52
|
+
// template `createSpiralCarryoverIssue` emits, plus the `type::carryover`
|
|
53
|
+
// and bare `carryover` labels used by the vault/drift/docs strict fallbacks.
|
|
54
|
+
[/\[(SPIRAL|FAILED)\]/, 'spiral-failed-auto-carry'],
|
|
55
|
+
[/\[Carryover\]/i, 'carryover-class'],
|
|
56
|
+
[/\btype::?carryover\b/i, 'carryover-class'],
|
|
57
|
+
[/(^|[\s,"'=])carryover([\s,"']|$)/i, 'carryover-class'],
|
|
58
|
+
[/(^|[\s,"'=])broken-window([\s,"']|$)/i, 'broken-window-closure'],
|
|
59
|
+
[/\[Backlog-Sammel\]/i, 'overflow-collector'],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decide whether an issue-create command bypasses the cap.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} command — the full shell command (or a reconstructed argv join)
|
|
66
|
+
* @returns {{ exempt: boolean, reason: string|null }}
|
|
67
|
+
*/
|
|
68
|
+
export function classifyExemption(command) {
|
|
69
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
70
|
+
return { exempt: false, reason: null };
|
|
71
|
+
}
|
|
72
|
+
for (const [re, reason] of EXEMPT_RULES) {
|
|
73
|
+
if (re.test(command)) return { exempt: true, reason };
|
|
74
|
+
}
|
|
75
|
+
return { exempt: false, reason: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load the `issue-budget` config from the repo's instruction file
|
|
80
|
+
* (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
|
|
81
|
+
* `parseSessionConfig()` is deliberately NOT used here so the hook path does
|
|
82
|
+
* not pay for host-path resolution and owner.yaml I/O on every Bash call.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} repoRoot
|
|
85
|
+
* @returns {{ "max-per-session": number, mode: string, overflow: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function loadIssueBudgetConfig(repoRoot) {
|
|
88
|
+
const defaults = { 'max-per-session': 12, mode: 'strict', overflow: 'collect-issue' };
|
|
89
|
+
try {
|
|
90
|
+
const resolved = resolveInstructionFile(repoRoot);
|
|
91
|
+
if (!resolved) return defaults;
|
|
92
|
+
return _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
return defaults;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Absolute path of the runtime counter file for a repo.
|
|
100
|
+
* @param {string} repoRoot
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function budgetStatePath(repoRoot) {
|
|
104
|
+
return path.join(repoRoot, BUDGET_STATE_REL);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the counter file. A missing, malformed, or foreign-session file yields
|
|
109
|
+
* a fresh zeroed state for `sessionId` — the counter is per session by
|
|
110
|
+
* construction, so a new session never inherits the previous session's spend.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} repoRoot
|
|
113
|
+
* @param {string|null} sessionId
|
|
114
|
+
* @returns {{ sessionId: string|null, count: number, exempt: number, overflow: object[] }}
|
|
115
|
+
*/
|
|
116
|
+
export function readBudgetState(repoRoot, sessionId) {
|
|
117
|
+
const fresh = { sessionId: sessionId ?? null, count: 0, exempt: 0, overflow: [] };
|
|
118
|
+
const file = budgetStatePath(repoRoot);
|
|
119
|
+
if (!existsSync(file)) return fresh;
|
|
120
|
+
try {
|
|
121
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
122
|
+
if (!data || typeof data !== 'object') return fresh;
|
|
123
|
+
if (sessionId && data.sessionId && data.sessionId !== sessionId) return fresh;
|
|
124
|
+
return {
|
|
125
|
+
sessionId: data.sessionId ?? sessionId ?? null,
|
|
126
|
+
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
127
|
+
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
128
|
+
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return fresh;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Persist the counter file. Best-effort: a write failure never blocks a
|
|
137
|
+
* creation (fail-open), it only means the count is under-reported.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} repoRoot
|
|
140
|
+
* @param {object} state
|
|
141
|
+
* @returns {boolean} true on success
|
|
142
|
+
*/
|
|
143
|
+
export function writeBudgetState(repoRoot, state) {
|
|
144
|
+
const res = writeJsonAtomicSync(budgetStatePath(repoRoot), state, {
|
|
145
|
+
tmpPrefix: '.issue-budget',
|
|
146
|
+
});
|
|
147
|
+
return res.ok === true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Charge one issue creation against the session budget and return the verdict.
|
|
152
|
+
*
|
|
153
|
+
* Decision table:
|
|
154
|
+
* mode `off` → `{ decision: 'off' }`, no state written
|
|
155
|
+
* exempt command → `{ decision: 'exempt' }`, `exempt` counter++
|
|
156
|
+
* count < max → `{ decision: 'allow' }`, `count`++
|
|
157
|
+
* count >= max, mode `warn` → `{ decision: 'warn' }`, `count`++ (creation proceeds)
|
|
158
|
+
* count >= max, mode `strict` → `{ decision: 'block' }`, overflow record appended
|
|
159
|
+
*
|
|
160
|
+
* In `strict` the blocked creation is NOT counted (it never happened); the
|
|
161
|
+
* request is parked in `overflow[]` so session-end can file exactly one
|
|
162
|
+
* collector issue instead of losing the item.
|
|
163
|
+
*
|
|
164
|
+
* @param {{
|
|
165
|
+
* repoRoot: string,
|
|
166
|
+
* sessionId?: string|null,
|
|
167
|
+
* command: string,
|
|
168
|
+
* title?: string|null,
|
|
169
|
+
* config?: { "max-per-session": number, mode: string, overflow: string },
|
|
170
|
+
* now?: string,
|
|
171
|
+
* }} opts
|
|
172
|
+
* @returns {{
|
|
173
|
+
* decision: 'off'|'exempt'|'allow'|'warn'|'block',
|
|
174
|
+
* count: number,
|
|
175
|
+
* max: number,
|
|
176
|
+
* mode: string,
|
|
177
|
+
* overflowSink: string,
|
|
178
|
+
* overflowPath: string,
|
|
179
|
+
* overflowCount: number,
|
|
180
|
+
* reason?: string|null,
|
|
181
|
+
* }}
|
|
182
|
+
*/
|
|
183
|
+
export function chargeIssueBudget({
|
|
184
|
+
repoRoot,
|
|
185
|
+
sessionId = null,
|
|
186
|
+
command,
|
|
187
|
+
title = null,
|
|
188
|
+
config,
|
|
189
|
+
now = new Date().toISOString(),
|
|
190
|
+
}) {
|
|
191
|
+
const cfg = config ?? loadIssueBudgetConfig(repoRoot);
|
|
192
|
+
const max = cfg['max-per-session'];
|
|
193
|
+
const mode = cfg.mode;
|
|
194
|
+
const overflowSink = cfg.overflow;
|
|
195
|
+
const overflowPath = budgetStatePath(repoRoot);
|
|
196
|
+
|
|
197
|
+
const base = { max, mode, overflowSink, overflowPath };
|
|
198
|
+
|
|
199
|
+
if (mode === 'off') {
|
|
200
|
+
return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const state = readBudgetState(repoRoot, sessionId);
|
|
204
|
+
state.sessionId = sessionId ?? state.sessionId;
|
|
205
|
+
|
|
206
|
+
const { exempt, reason } = classifyExemption(command);
|
|
207
|
+
if (exempt) {
|
|
208
|
+
state.exempt += 1;
|
|
209
|
+
writeBudgetState(repoRoot, state);
|
|
210
|
+
return {
|
|
211
|
+
...base,
|
|
212
|
+
decision: 'exempt',
|
|
213
|
+
count: state.count,
|
|
214
|
+
overflowCount: state.overflow.length,
|
|
215
|
+
reason,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (state.count < max) {
|
|
220
|
+
state.count += 1;
|
|
221
|
+
writeBudgetState(repoRoot, state);
|
|
222
|
+
return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (mode === 'warn') {
|
|
226
|
+
state.count += 1;
|
|
227
|
+
writeBudgetState(repoRoot, state);
|
|
228
|
+
return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// strict — park the request, do not count it.
|
|
232
|
+
state.overflow.push({
|
|
233
|
+
title: title ?? null,
|
|
234
|
+
command: String(command).slice(0, 500),
|
|
235
|
+
at: now,
|
|
236
|
+
});
|
|
237
|
+
writeBudgetState(repoRoot, state);
|
|
238
|
+
return {
|
|
239
|
+
...base,
|
|
240
|
+
decision: 'block',
|
|
241
|
+
count: state.count,
|
|
242
|
+
overflowCount: state.overflow.length,
|
|
243
|
+
reason: null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Human-readable block message. Shared by the hook (stderr + deny JSON) and the
|
|
249
|
+
* programmatic path so an agent sees the same instruction either way.
|
|
250
|
+
*
|
|
251
|
+
* @param {{ count: number, max: number, overflowPath: string, overflowSink: string, overflowCount: number }} v
|
|
252
|
+
* @returns {string}
|
|
253
|
+
*/
|
|
254
|
+
export function formatBlockReason(v) {
|
|
255
|
+
const sink =
|
|
256
|
+
v.overflowSink === 'vault-note'
|
|
257
|
+
? 'a note under `vault/00-inbox/`'
|
|
258
|
+
: 'ONE collector issue `[Backlog-Sammel] <session-id>, N zurückgestellte Punkte`';
|
|
259
|
+
return [
|
|
260
|
+
`issue-budget: session cap reached — ${v.count}/${v.max} issues already created.`,
|
|
261
|
+
`This request was NOT created. It is parked as overflow entry #${v.overflowCount} in:`,
|
|
262
|
+
` ${v.overflowPath}`,
|
|
263
|
+
`session-end Phase 5 will fold all overflow entries into ${sink}. Nothing is lost.`,
|
|
264
|
+
`Exempt from the cap: priority::critical, the carryover class (SPIRAL/FAILED, [Carryover]),`,
|
|
265
|
+
`and broken-window closure issues — those are never deferred.`,
|
|
266
|
+
`To raise the cap for this repo, edit \`issue-budget.max-per-session\` in the Session Config;`,
|
|
267
|
+
`\`mode: warn\` reports without blocking, \`mode: off\` disables the gate.`,
|
|
268
|
+
].join('\n');
|
|
269
|
+
}
|