session-orchestrator 3.22.0 → 3.23.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -18,11 +18,20 @@
|
|
|
18
18
|
* G1 tool filter — only Bash is gated.
|
|
19
19
|
* G2 command is a non-empty string.
|
|
20
20
|
* G3 matcher — `gh|glab … issue create|new` only. PR/MR creation passes.
|
|
21
|
+
* Verb-resolved since #1145, so a wrapped (`nohup`), absolute-path or
|
|
22
|
+
* env-prefixed create is seen; a `--help` invocation is not (it creates
|
|
23
|
+
* nothing).
|
|
21
24
|
* G4 config — `issue-budget` from CLAUDE.md/AGENTS.md. `mode: off` → allow.
|
|
25
|
+
* G3b bulk — a create inside a shell LOOP body creates an unknowable number
|
|
26
|
+
* of issues (#1145). `strict` → deny; `warn` → allow with an explicit
|
|
27
|
+
* undercount notice. See the block comment above formatLoopDenyReason for
|
|
28
|
+
* why not "charge 1".
|
|
22
29
|
* G5 exemption — priority::critical / carryover class / broken-window /
|
|
23
30
|
* the overflow collector itself bypass the cap unconditionally, keeping
|
|
24
31
|
* the session-end promises at SKILL.md:319 and :1113 intact.
|
|
25
|
-
* G6 charge the counter in .orchestrator/runtime/issue-budget
|
|
32
|
+
* G6 charge the counter in .orchestrator/runtime/issue-budget/<hash>.json
|
|
33
|
+
* (one file per session since #1141 — see scripts/lib/issue-budget.mjs
|
|
34
|
+
* `budgetStateRel`).
|
|
26
35
|
* under cap → allow; over cap + `warn` → allow with stderr notice;
|
|
27
36
|
* over cap + `strict` → park in `overflow[]`, then deny via emitDeny.
|
|
28
37
|
*
|
|
@@ -38,14 +47,15 @@
|
|
|
38
47
|
* which would throw away the deny envelope entirely (#906).
|
|
39
48
|
*/
|
|
40
49
|
|
|
41
|
-
import { readStdin, emitAllow, emitDeny } from '../scripts/lib/io.mjs';
|
|
50
|
+
import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
|
|
42
51
|
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
43
52
|
import { readJson } from '../scripts/lib/common.mjs';
|
|
44
|
-
import { isIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
|
|
53
|
+
import { isIssueCreate, isLoopedIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
|
|
45
54
|
import {
|
|
46
55
|
loadIssueBudgetConfig,
|
|
47
56
|
resolveIssueBudgetSessionId,
|
|
48
57
|
chargeIssueBudget,
|
|
58
|
+
classifyExemption,
|
|
49
59
|
formatBlockReason,
|
|
50
60
|
} from '../scripts/lib/issue-budget.mjs';
|
|
51
61
|
|
|
@@ -66,12 +76,28 @@ if (!shouldRunHook('pre-bash-issue-budget')) process.exit(0);
|
|
|
66
76
|
* semantic id can bridge repeated calls only when its recorded raw id exactly
|
|
67
77
|
* matches the native stdin id; it never substitutes for a missing raw id.
|
|
68
78
|
*
|
|
79
|
+
* ENV FALLBACK (#1141). A PreToolUse payload without `session_id` used to
|
|
80
|
+
* resolve to `null`, and an identity-less charge neither reads nor persists —
|
|
81
|
+
* so for that payload shape the cap was silently OFF. The harness also exports
|
|
82
|
+
* `CLAUDE_CODE_SESSION_ID` (measured: it equals the `session.lock` `session_id`
|
|
83
|
+
* and survives into subagents), which is a native id of the same kind as the
|
|
84
|
+
* stdin one, so it is a faithful substitute rather than a guess. Note the
|
|
85
|
+
* NAME: there is no `CLAUDE_SESSION_ID` — reading that spelling is what left
|
|
86
|
+
* the identical fallback in `scripts/lib/spiral-carryover.mjs` dead code.
|
|
87
|
+
*
|
|
88
|
+
* stdin still wins: it is the id of THIS tool call, whereas the env var is the
|
|
89
|
+
* id of the process tree, and the two differ in a nested harness.
|
|
90
|
+
*
|
|
69
91
|
* @param {object|null} input
|
|
70
92
|
* @param {string|null} projectDir
|
|
71
93
|
* @returns {Promise<string|null>}
|
|
72
94
|
*/
|
|
73
95
|
async function resolveSessionId(input, projectDir) {
|
|
74
|
-
const
|
|
96
|
+
const stdinRawId = input?.session_id ?? input?.sessionId ?? null;
|
|
97
|
+
const nativeRawId =
|
|
98
|
+
typeof stdinRawId === 'string' && stdinRawId.length > 0
|
|
99
|
+
? stdinRawId
|
|
100
|
+
: (process.env.CLAUDE_CODE_SESSION_ID ?? null);
|
|
75
101
|
if (typeof nativeRawId !== 'string' || nativeRawId.length === 0) return null;
|
|
76
102
|
|
|
77
103
|
let currentSession = null;
|
|
@@ -88,6 +114,68 @@ async function resolveSessionId(input, projectDir) {
|
|
|
88
114
|
return resolveIssueBudgetSessionId(nativeRawId, currentSession);
|
|
89
115
|
}
|
|
90
116
|
|
|
117
|
+
/**
|
|
118
|
+
* THE CHOICE (#1145) — stated once, so the loop behaviour is explicit rather
|
|
119
|
+
* than emergent.
|
|
120
|
+
*
|
|
121
|
+
* `for t in a b c; do glab issue create --title $t; done` is textually ONE
|
|
122
|
+
* create statement that files THREE issues. Three answers were available and
|
|
123
|
+
* none is obviously right:
|
|
124
|
+
*
|
|
125
|
+
* charge 1 — the ledger then carries a number it KNOWS is wrong.
|
|
126
|
+
* `for i in $(seq 1 50)` files 50 issues against a count of 1, so
|
|
127
|
+
* the cap stays nominally armed while actually uncapped. That is
|
|
128
|
+
* strictly worse than the pre-#1145 miss, because the pre-fix
|
|
129
|
+
* state at least did not LOOK accounted.
|
|
130
|
+
* charge N — not computable at hook time. The word list can be
|
|
131
|
+
* `$(cat backlog.txt)`, `"$@"`, or a glob; this hook runs BEFORE
|
|
132
|
+
* the shell expands any of them.
|
|
133
|
+
* deny — CHOSEN. A quantity gate that cannot count the quantity must
|
|
134
|
+
* refuse, not guess. It is fully recoverable: unrolling the loop
|
|
135
|
+
* into separate create calls charges each one correctly, and the
|
|
136
|
+
* deny reason says exactly that. Denying costs one round-trip;
|
|
137
|
+
* guessing costs the cap its credibility.
|
|
138
|
+
*
|
|
139
|
+
* The choice is mode-scoped, because `mode` is the operator's declared
|
|
140
|
+
* enforcement level and this gate has no standing to exceed it:
|
|
141
|
+
* strict → deny; warn → allow + an explicit undercount notice; off → allow.
|
|
142
|
+
* The exemption classes (priority::critical, carryover, broken-window) are
|
|
143
|
+
* checked FIRST and pass through untouched, so session-end's "those are never
|
|
144
|
+
* deferred" promise survives a looped carryover sweep.
|
|
145
|
+
*
|
|
146
|
+
* NAMED CEILING (BV-004): a loop is detected by `do`/`done` in command position
|
|
147
|
+
* (see `isLoopedIssueCreate`), so an UNROLLED bulk create — 50 create statements
|
|
148
|
+
* chained with `&&` — is not a "loop" and is charged 50, correctly. Revisit this
|
|
149
|
+
* choice if the overflow triage of a per-session counter file
|
|
150
|
+
* (`.orchestrator/runtime/issue-budget/<hash>.json`) shows operators routinely
|
|
151
|
+
* hitting this deny on loops over a KNOWN literal word list; the cheap answer
|
|
152
|
+
* then is to count that list, never to fall back to "charge 1".
|
|
153
|
+
*
|
|
154
|
+
* Deliberately NOT `formatBlockReason`: that text promises "parked as overflow
|
|
155
|
+
* entry #N … nothing is lost", which would be false here — an uncountable bulk
|
|
156
|
+
* request is not parked, it is handed back whole.
|
|
157
|
+
*
|
|
158
|
+
* @param {{ "max-per-session": number }} config
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function formatLoopDenyReason(config) {
|
|
162
|
+
return [
|
|
163
|
+
'issue-budget: this command creates an UNKNOWN number of issues — refusing to guess.',
|
|
164
|
+
'The `issue create` call sits inside a shell loop body (`do … done`), so the cap cannot',
|
|
165
|
+
'charge it honestly: the word list is expanded by the shell AFTER this hook runs, so',
|
|
166
|
+
'`for i in $(seq 1 50)` would file 50 issues against a count of 1.',
|
|
167
|
+
'',
|
|
168
|
+
'Nothing was parked as overflow, because nothing is lost: re-issue the create calls as',
|
|
169
|
+
'SEPARATE commands and each one is counted normally against the cap',
|
|
170
|
+
`(${config['max-per-session']} per session).`,
|
|
171
|
+
'',
|
|
172
|
+
'Exempt from the cap even inside a loop: priority::critical, the carryover class',
|
|
173
|
+
'(SPIRAL/FAILED, [Carryover]), and broken-window closure issues.',
|
|
174
|
+
'To change the enforcement level, edit `issue-budget.mode` in the Session Config:',
|
|
175
|
+
'`warn` reports without blocking, `off` disables the gate.',
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
91
179
|
// ---------------------------------------------------------------------------
|
|
92
180
|
// Main
|
|
93
181
|
// ---------------------------------------------------------------------------
|
|
@@ -112,6 +200,17 @@ async function main() {
|
|
|
112
200
|
const config = loadIssueBudgetConfig(projectDir);
|
|
113
201
|
if (config.mode === 'off') return emitAllow();
|
|
114
202
|
|
|
203
|
+
// G3b — bulk creation whose multiplicity is not computable (#1145). The
|
|
204
|
+
// exemption is asked FIRST, through the same classifier chargeIssueBudget
|
|
205
|
+
// uses, so a looped carryover sweep keeps its unconditional pass.
|
|
206
|
+
const uncountableBulk =
|
|
207
|
+
isLoopedIssueCreate(command) && !classifyExemption(command).exempt;
|
|
208
|
+
if (uncountableBulk && config.mode === 'strict') {
|
|
209
|
+
// Nothing is charged and nothing is parked — the command is handed back
|
|
210
|
+
// whole, which is what makes unrolling it the correct next action.
|
|
211
|
+
return emitDeny(formatLoopDenyReason(config));
|
|
212
|
+
}
|
|
213
|
+
|
|
115
214
|
const sessionId = await resolveSessionId(input, projectDir);
|
|
116
215
|
|
|
117
216
|
// G5 + G6 — exemption check and counter charge live in the shared core so
|
|
@@ -153,6 +252,20 @@ async function main() {
|
|
|
153
252
|
emitDeny(formatBlockReason(verdict));
|
|
154
253
|
}
|
|
155
254
|
|
|
255
|
+
// A PERMITTED bulk create is charged ONCE, which is an undercount by
|
|
256
|
+
// construction. `mode: warn` means "report, do not block", so the report has
|
|
257
|
+
// to name the undercount out loud — a silent 1-for-N is the exact failure the
|
|
258
|
+
// deny above exists to prevent, and `warn` must not reintroduce it quietly.
|
|
259
|
+
// emitWarn, not stderr: under exit 0 stderr reaches only the debug log (#916).
|
|
260
|
+
if (uncountableBulk && verdict.decision === 'allow') {
|
|
261
|
+
return emitWarn(
|
|
262
|
+
`pre-bash-issue-budget: bulk create inside a loop body charged as 1 ` +
|
|
263
|
+
`(${verdict.count}/${verdict.max}) — the real number of issues this files is not ` +
|
|
264
|
+
`knowable before the shell expands the word list, so the count is an UNDERCOUNT. ` +
|
|
265
|
+
`Set \`issue-budget.mode: strict\` to deny this shape instead.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
156
269
|
// 'allow' / 'off'
|
|
157
270
|
return emitAllow();
|
|
158
271
|
}
|
|
@@ -40,6 +40,40 @@
|
|
|
40
40
|
* sessions.jsonl` — piping the writer's `{"action":"appended"}` receipt INTO
|
|
41
41
|
* the ledger. That is a bug, and denying it is correct.
|
|
42
42
|
*
|
|
43
|
+
* ## The repair CLI (GitLab #385) — allowed by EXACT MATCH, not structurally
|
|
44
|
+
*
|
|
45
|
+
* `repair-invalid-sessions.mjs --apply` is a FIFTH sanctioned writer, but it
|
|
46
|
+
* is allowed by a different mechanism than the four above: it mutates the
|
|
47
|
+
* ledger through `repairRecord()` (`scripts/lib/session-record-repair.mjs`)
|
|
48
|
+
* from inside Node, so structurally it is identical to the four writers — no
|
|
49
|
+
* ledger write-intent for the matcher to see — EXCEPT that this hook's OWN
|
|
50
|
+
* matcher independently resolves its `node <script> --apply` argv shape as an
|
|
51
|
+
* ledger-mutating verb (`findWriteVerbTarget`'s `verb === 'node'` branch,
|
|
52
|
+
* #1005) and denies it on sight. Before this hook had any opinion on the
|
|
53
|
+
* subject at all, the guard was therefore denying its own validating writer —
|
|
54
|
+
* the fix-it CLI for a corrupted ledger record could never run, which is the
|
|
55
|
+
* #385 deadlock.
|
|
56
|
+
*
|
|
57
|
+
* The fix (`REPAIR_APPLY_CANONICAL_COMMAND`, see below) is an EXACT STRING
|
|
58
|
+
* comparison against the trimmed command — not a name check, and not
|
|
59
|
+
* equivalent to the spoofable name allowlist rejected above. The distinction
|
|
60
|
+
* that matters: a name allowlist matches a SUBSTRING anywhere in the command
|
|
61
|
+
* (a comment, an argument, a quoted payload), so an attacker can smuggle the
|
|
62
|
+
* name into a command that does something else entirely. An exact-command
|
|
63
|
+
* comparison has no such position to smuggle into — the command either IS,
|
|
64
|
+
* in its entirety, `node scripts/repair-invalid-sessions.mjs --apply`, or it
|
|
65
|
+
* is not, and ANY addition (a flag, a wrapper, a second command chained
|
|
66
|
+
* after it, a subshell) breaks the match and falls through to the existing
|
|
67
|
+
* deny logic unchanged (`sonst weiter DENY` — see the #385 tests). There is
|
|
68
|
+
* still nothing to spoof, for the same reason the four structural writers
|
|
69
|
+
* have nothing to spoof: allowing this ONE exact invocation grants nothing an
|
|
70
|
+
* attacker did not already have. `repairRecord()` validates every record it
|
|
71
|
+
* writes the same way `emit-session.mjs` does, so a caller who can already
|
|
72
|
+
* invoke arbitrary Bash could run this repair CLI directly, or edit
|
|
73
|
+
* `session-record-repair.mjs` itself, without ever touching this hook — the
|
|
74
|
+
* hook's protection scope is shell-redirect writes that BYPASS validation,
|
|
75
|
+
* and this command never does that.
|
|
76
|
+
*
|
|
43
77
|
* ## Path scope
|
|
44
78
|
*
|
|
45
79
|
* A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
|
|
@@ -172,6 +206,11 @@
|
|
|
172
206
|
* verbs (a redirect inside an `awk` program string is quoted data here).
|
|
173
207
|
* - The Write/Edit tools — a different PreToolUse matcher entirely
|
|
174
208
|
* (`hooks/enforce-scope.mjs` territory), not this hook's surface.
|
|
209
|
+
* - The #385 repair-apply subshell/backtick deny (`unwrapSubshellLayers`)
|
|
210
|
+
* only unwraps a command that IS, in its entirety, one wrapped run —
|
|
211
|
+
* `x=$(node scripts/repair-invalid-sessions.mjs --apply)` (the wrapper is
|
|
212
|
+
* part of a larger assignment, not the whole command) still allows. Same
|
|
213
|
+
* class as the line above: a determined rewrite, not the accident shape.
|
|
175
214
|
*
|
|
176
215
|
* This is a guard against the accident that actually happened, not a
|
|
177
216
|
* containment boundary. Determined circumvention is out of scope by design.
|
|
@@ -351,6 +390,29 @@ const LEDGER_BASENAME = 'sessions.jsonl';
|
|
|
351
390
|
const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
|
|
352
391
|
const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
353
392
|
|
|
393
|
+
/**
|
|
394
|
+
* The ONE command string this guard treats as a sanctioned, exact-match
|
|
395
|
+
* repair-apply invocation (GitLab #385).
|
|
396
|
+
*
|
|
397
|
+
* Without this exception the guard denied its OWN validating writer:
|
|
398
|
+
* `repair-invalid-sessions.mjs --apply` mutates the ledger through
|
|
399
|
+
* `repairRecord()` in `scripts/lib/session-record-repair.mjs` — the same
|
|
400
|
+
* validated-write discipline this hook exists to enforce (mandatory `.bak`,
|
|
401
|
+
* atomic rename, post-verification restore on failure) — yet the module
|
|
402
|
+
* docblock's own "ALLOWED STRUCTURALLY" section (see the top of this file)
|
|
403
|
+
* never named it, because the matcher below still resolves `node` as the verb
|
|
404
|
+
* and denies on sight. The fix is deliberately an EXACT STRING match, not a
|
|
405
|
+
* pattern: matching by verb/flags (as `findWriteVerbTarget` already does for
|
|
406
|
+
* the deny case) cannot distinguish "the operator ran the sanctioned
|
|
407
|
+
* runbook command" from "the operator ran something ADJACENT to it" — a
|
|
408
|
+
* wrapper, an extra flag, a second command chained after it. `sonst weiter
|
|
409
|
+
* DENY` (issue text): any deviation — chaining, piping, a wrapper, extra
|
|
410
|
+
* arguments, a subshell, backticks — falls through UNCHANGED to the existing
|
|
411
|
+
* deny logic below, which is why this constant is compared against the RAW,
|
|
412
|
+
* merely-trimmed command string before any tokenizing/unwrapping happens.
|
|
413
|
+
*/
|
|
414
|
+
const REPAIR_APPLY_CANONICAL_COMMAND = `node scripts/${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
415
|
+
|
|
354
416
|
/**
|
|
355
417
|
* How much of the offending target may appear in the deny reason.
|
|
356
418
|
*
|
|
@@ -416,6 +478,68 @@ function refersToRepairScript(target) {
|
|
|
416
478
|
return path.posix.basename(normalized) === REPAIR_SCRIPT_BASENAME;
|
|
417
479
|
}
|
|
418
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Strip exactly ONE layer of `$( … )`, backtick, or bare `( … )` wrapping from
|
|
483
|
+
* `s`, when `s` (already trimmed) is wholly that one wrapped run. Returns
|
|
484
|
+
* `null` when `s` is not wrapped, or when a bare-paren/`$(…)` wrapper's
|
|
485
|
+
* interior has unbalanced parens (ambiguous — leave it alone rather than
|
|
486
|
+
* unwrap something that was not really a single wrapped command).
|
|
487
|
+
*
|
|
488
|
+
* Deliberately narrow: this is NOT a general shell parser. It exists only to
|
|
489
|
+
* answer "is this string exactly the canonical repair-apply command sitting
|
|
490
|
+
* inside one layer of command-substitution/subshell syntax" (see
|
|
491
|
+
* {@link unwrapSubshellLayers} and GitLab #385 test (c)) — the general
|
|
492
|
+
* `blocker.tokenizeCommand` path glues a leading `$(`/`` ` ``/`(` onto the
|
|
493
|
+
* next word (#385 measured: `$(node …)` tokenizes as a single `$(node`
|
|
494
|
+
* token), which is why the existing verb-based matcher never resolves `node`
|
|
495
|
+
* as the verb for a wrapped command and silently ALLOWS it today.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} s - already-trimmed candidate
|
|
498
|
+
* @returns {string|null} the unwrapped, trimmed interior, or null
|
|
499
|
+
*/
|
|
500
|
+
function unwrapSingleSubshellLayer(s) {
|
|
501
|
+
const balanced = (inner) => {
|
|
502
|
+
let depth = 0;
|
|
503
|
+
for (const ch of inner) {
|
|
504
|
+
if (ch === '(') depth++;
|
|
505
|
+
else if (ch === ')') { depth--; if (depth < 0) return false; }
|
|
506
|
+
}
|
|
507
|
+
return depth === 0;
|
|
508
|
+
};
|
|
509
|
+
if (s.length >= 4 && s.startsWith('$(') && s.endsWith(')')) {
|
|
510
|
+
const inner = s.slice(2, -1);
|
|
511
|
+
return balanced(inner) ? inner.trim() : null;
|
|
512
|
+
}
|
|
513
|
+
if (s.length >= 2 && s.startsWith('`') && s.endsWith('`')) {
|
|
514
|
+
return s.slice(1, -1).trim();
|
|
515
|
+
}
|
|
516
|
+
if (s.length >= 2 && s.startsWith('(') && s.endsWith(')')) {
|
|
517
|
+
const inner = s.slice(1, -1);
|
|
518
|
+
return balanced(inner) ? inner.trim() : null;
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Repeatedly apply {@link unwrapSingleSubshellLayer} up to `maxDepth` times.
|
|
525
|
+
* Bounded the same way payload recursion is bounded elsewhere in this file —
|
|
526
|
+
* no realistic hand-typed command nests this deep, and the outer command is
|
|
527
|
+
* still matched on its own terms regardless of how far this unwrapping gets.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} command
|
|
530
|
+
* @param {number} [maxDepth]
|
|
531
|
+
* @returns {string}
|
|
532
|
+
*/
|
|
533
|
+
function unwrapSubshellLayers(command, maxDepth = 3) {
|
|
534
|
+
let current = command.trim();
|
|
535
|
+
for (let n = 0; n < maxDepth; n++) {
|
|
536
|
+
const next = unwrapSingleSubshellLayer(current);
|
|
537
|
+
if (next === null) break;
|
|
538
|
+
current = next;
|
|
539
|
+
}
|
|
540
|
+
return current;
|
|
541
|
+
}
|
|
542
|
+
|
|
419
543
|
/**
|
|
420
544
|
* Node's own `--help` output is the source of truth for the runtime-option
|
|
421
545
|
* grammar. A hand-maintained option set is necessarily stale as Node adds
|
|
@@ -974,6 +1098,41 @@ async function main() {
|
|
|
974
1098
|
);
|
|
975
1099
|
}
|
|
976
1100
|
|
|
1101
|
+
// #385 — the repair CLI's own sanctioned invocation is the ONE write this
|
|
1102
|
+
// guard must let through: it mutates the ledger via the SAME validated-write
|
|
1103
|
+
// discipline (`repairRecord()`, mandatory backup, atomic rename, post-verify
|
|
1104
|
+
// restore) this hook exists to enforce, not a hand-composed shell append.
|
|
1105
|
+
// Compared against the RAW, merely-trimmed command — before any
|
|
1106
|
+
// tokenizing/sanitizing — so the exception is exactly as narrow as the
|
|
1107
|
+
// string itself; "conservative" per the issue text means matching the
|
|
1108
|
+
// string, not a pattern that could also match something adjacent to it.
|
|
1109
|
+
const trimmedCommand = command.trim();
|
|
1110
|
+
if (trimmedCommand === REPAIR_APPLY_CANONICAL_COMMAND) {
|
|
1111
|
+
return flushNotices(notices);
|
|
1112
|
+
}
|
|
1113
|
+
// The SAME canonical command wrapped in one layer of command substitution /
|
|
1114
|
+
// backticks / a bare subshell is a DIFFERENT command — bash still executes
|
|
1115
|
+
// the repair mutation for the substitution's side effect, but it no longer
|
|
1116
|
+
// matches the exact sanctioned string, so `sonst weiter DENY` applies. This
|
|
1117
|
+
// also closes a real gap the exact-match alone would not: `blocker`'s
|
|
1118
|
+
// tokenizer glues a leading `$(`/`` ` ``/`(` onto `node`, so the EXISTING
|
|
1119
|
+
// verb-based matcher below never resolves verb `node` for a wrapped command
|
|
1120
|
+
// and silently allowed it before this hook had any opinion on the subject.
|
|
1121
|
+
if (unwrapSubshellLayers(trimmedCommand) === REPAIR_APPLY_CANONICAL_COMMAND) {
|
|
1122
|
+
emitDeny(
|
|
1123
|
+
[
|
|
1124
|
+
`Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER} (wrapped in a subshell/backticks — denied)'`,
|
|
1125
|
+
`The sanctioned repair invocation is allowed ONLY as the exact bare command:`,
|
|
1126
|
+
` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
|
|
1127
|
+
`Wrapping it in $(...), backticks, or a subshell is denied even though the`,
|
|
1128
|
+
`command is otherwise identical — see hooks/pre-bash-sessions-ledger-guard.mjs #385.`,
|
|
1129
|
+
`Override (intentional maintenance only): run the session with`,
|
|
1130
|
+
`SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard`,
|
|
1131
|
+
].join('\n'),
|
|
1132
|
+
);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
977
1136
|
// G3 — matcher. No direct ledger write → allow.
|
|
978
1137
|
const marks = [];
|
|
979
1138
|
const target = findLedgerWrite(command, 0, marks);
|
|
@@ -64,6 +64,7 @@ import os from 'node:os';
|
|
|
64
64
|
import crypto from 'node:crypto';
|
|
65
65
|
|
|
66
66
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
67
|
+
import { stableHostname } from '../scripts/lib/host-identity.mjs';
|
|
67
68
|
if (!shouldRunHook('pre-bash-staging-fence')) process.exit(0);
|
|
68
69
|
|
|
69
70
|
// ---------------------------------------------------------------------------
|
|
@@ -159,7 +160,10 @@ function appendIntent({ fenceFile, agentId, command }) {
|
|
|
159
160
|
body = {
|
|
160
161
|
agent_id: agentId,
|
|
161
162
|
pid: process.pid,
|
|
163
|
+
// `host` raw + `host_id` normalised (#1072 — os.hostname() flips spelling
|
|
164
|
+
// on a single machine, so a raw value is not comparable across writes).
|
|
162
165
|
host: os.hostname(),
|
|
166
|
+
host_id: stableHostname(),
|
|
163
167
|
started_at: timestamp,
|
|
164
168
|
staged_paths: [],
|
|
165
169
|
};
|