session-orchestrator 5.1.0 → 5.2.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/autopilot/SKILL.md +1 -0
- package/.agents/skills/bootstrap/SKILL.md +2 -0
- package/.agents/skills/brainstorm/SKILL.md +3 -0
- package/.agents/skills/close/SKILL.md +17 -0
- package/.agents/skills/debug/SKILL.md +2 -0
- package/.agents/skills/discovery/SKILL.md +2 -1
- package/.agents/skills/dispatcher/SKILL.md +2 -0
- package/.agents/skills/eli5/SKILL.md +2 -0
- package/.agents/skills/eval/SKILL.md +1 -0
- package/.agents/skills/evolve/SKILL.md +2 -1
- package/.agents/skills/go/SKILL.md +18 -0
- package/.agents/skills/grill/SKILL.md +2 -0
- package/.agents/skills/harness-audit/SKILL.md +16 -0
- package/.agents/skills/memory-cleanup/SKILL.md +1 -0
- package/.agents/skills/persona-panel/SKILL.md +1 -0
- package/.agents/skills/plan/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +17 -0
- package/.agents/skills/reconcile/SKILL.md +1 -0
- package/.agents/skills/release/SKILL.md +18 -0
- package/.agents/skills/repo-audit/SKILL.md +1 -0
- package/.agents/skills/spinout/SKILL.md +1 -0
- package/.agents/skills/sunset-review/SKILL.md +2 -0
- package/.agents/skills/test/SKILL.md +17 -0
- package/.agents/skills/ux-grill/SKILL.md +2 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
- package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
- package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
- package/.codex-plugin/skills/close/SKILL.md +3 -3
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/debug/SKILL.md +11 -4
- package/.codex-plugin/skills/discovery/SKILL.md +8 -4
- package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
- package/.codex-plugin/skills/eli5/SKILL.md +9 -4
- package/.codex-plugin/skills/eval/SKILL.md +9 -4
- package/.codex-plugin/skills/evolve/SKILL.md +9 -4
- package/.codex-plugin/skills/go/SKILL.md +3 -3
- package/.codex-plugin/skills/grill/SKILL.md +11 -4
- package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
- package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
- package/.codex-plugin/skills/plan/SKILL.md +8 -4
- package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
- package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
- package/.codex-plugin/skills/release/SKILL.md +3 -3
- package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
- package/.codex-plugin/skills/spinout/SKILL.md +4 -4
- package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
- package/.codex-plugin/skills/test/SKILL.md +3 -3
- package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
- package/.cursor/commands/autopilot.md +4 -4
- package/.cursor/commands/bootstrap.md +5 -4
- package/.cursor/commands/brainstorm.md +5 -4
- package/.cursor/commands/close.md +4 -3
- package/.cursor/commands/convergence-monitoring.md +13 -0
- package/.cursor/commands/debug.md +4 -4
- package/.cursor/commands/discovery.md +4 -4
- package/.cursor/commands/dispatcher.md +4 -4
- package/.cursor/commands/eli5.md +4 -4
- package/.cursor/commands/eval.md +4 -4
- package/.cursor/commands/evolve.md +4 -4
- package/.cursor/commands/go.md +4 -3
- package/.cursor/commands/grill.md +4 -4
- package/.cursor/commands/harness-audit.md +3 -3
- package/.cursor/commands/memory-cleanup.md +4 -4
- package/.cursor/commands/npm-publish.md +13 -0
- package/.cursor/commands/persona-panel.md +4 -4
- package/.cursor/commands/plan.md +5 -4
- package/.cursor/commands/portfolio.md +3 -3
- package/.cursor/commands/reconcile.md +4 -4
- package/.cursor/commands/release.md +4 -3
- package/.cursor/commands/repo-audit.md +4 -4
- package/.cursor/commands/spinout.md +4 -4
- package/.cursor/commands/sunset-review.md +4 -4
- package/.cursor/commands/test.md +3 -3
- package/.cursor/commands/ux-grill.md +4 -4
- package/.cursor/rules/010-session-workflow.mdc +2 -2
- package/.cursor/skills/bootstrap/SKILL.md +1 -0
- package/.cursor/skills/close/SKILL.md +13 -0
- package/.cursor/skills/debug/SKILL.md +0 -1
- package/.cursor/skills/discovery/SKILL.md +0 -1
- package/.cursor/skills/dispatcher/SKILL.md +0 -1
- package/.cursor/skills/eli5/SKILL.md +0 -1
- package/.cursor/skills/evolve/SKILL.md +0 -1
- package/.cursor/skills/go/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +0 -1
- package/.cursor/skills/harness-audit/SKILL.md +12 -0
- package/.cursor/skills/portfolio/SKILL.md +12 -0
- package/.cursor/skills/release/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +0 -1
- package/.cursor/skills/sunset-review/SKILL.md +0 -1
- package/.cursor/skills/test/SKILL.md +12 -0
- package/.cursor/skills/ux-grill/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +61 -0
- package/README.md +11 -9
- package/commands/session.md +10 -0
- package/docs/ci-setup.md +53 -0
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +11 -6
- package/docs/events-schema.md +4 -1
- package/docs/install.md +16 -0
- package/docs/persona-panel.md +1 -1
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +83 -14
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +6 -4
- package/hooks/_lib/hook-import-set.json +46 -6
- package/hooks/_lib/subagent-paths.mjs +15 -0
- package/hooks/_lib/vcs-create-matcher.mjs +217 -62
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +14 -2
- package/hooks/on-stop.mjs +43 -1
- package/hooks/post-bash-write-verify.mjs +3 -0
- package/hooks/pre-auq-clarity.mjs +3 -0
- package/hooks/pre-bash-issue-budget.mjs +103 -17
- package/hooks/pre-task-scope-disjoint.mjs +152 -3
- package/hooks/skill-invocation-telemetry.mjs +2 -1
- package/package.json +2 -1
- package/pi/prompts/autopilot.md +3 -3
- package/pi/prompts/bootstrap.md +3 -3
- package/pi/prompts/brainstorm.md +3 -3
- package/pi/prompts/close.md +2 -2
- package/pi/prompts/convergence-monitoring.md +11 -0
- package/pi/prompts/debug.md +3 -3
- package/pi/prompts/discovery.md +3 -3
- package/pi/prompts/dispatcher.md +3 -3
- package/pi/prompts/eli5.md +3 -3
- package/pi/prompts/eval.md +3 -3
- package/pi/prompts/evolve.md +3 -3
- package/pi/prompts/go.md +2 -2
- package/pi/prompts/grill.md +3 -3
- package/pi/prompts/harness-audit.md +2 -3
- package/pi/prompts/memory-cleanup.md +3 -3
- package/pi/prompts/npm-publish.md +11 -0
- package/pi/prompts/persona-panel.md +3 -3
- package/pi/prompts/plan.md +3 -3
- package/pi/prompts/portfolio.md +2 -2
- package/pi/prompts/reconcile.md +3 -3
- package/pi/prompts/release.md +3 -3
- package/pi/prompts/repo-audit.md +3 -4
- package/pi/prompts/session.md +1 -1
- package/pi/prompts/spinout.md +3 -3
- package/pi/prompts/sunset-review.md +3 -3
- package/pi/prompts/templates-ack.md +1 -1
- package/pi/prompts/test.md +3 -3
- package/pi/prompts/ux-grill.md +3 -3
- package/scripts/archive-closed-prds.mjs +2 -2
- package/scripts/auq-audit.mjs +2 -3
- package/scripts/backfill-abandoned-sessions.mjs +57 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +2 -2
- package/scripts/check-package-manager.mjs +2 -2
- package/scripts/ci/assert-vitest-green.mjs +2 -1
- package/scripts/emit-session.mjs +2 -3
- package/scripts/export-hw-learnings.mjs +2 -1
- package/scripts/express-path.mjs +1 -1
- package/scripts/gc-stale-worktrees.mjs +2 -1
- package/scripts/generate-codex-skills.mjs +48 -4
- package/scripts/generate-cursor-adapter.mjs +173 -9
- package/scripts/generate-hook-import-set.mjs +12 -27
- package/scripts/generate-pi-prompts.mjs +183 -13
- package/scripts/github-protection-audit.mjs +2 -3
- package/scripts/lib/agent-frontmatter.mjs +23 -1
- package/scripts/lib/claude-md-budget-lint.mjs +2 -5
- package/scripts/lib/command-blocker.mjs +133 -5
- package/scripts/lib/config/drift-check.mjs +19 -0
- package/scripts/lib/convergence-monitor.mjs +2 -2
- package/scripts/lib/cursor-hook-bridge.mjs +2 -2
- package/scripts/lib/description-surface.mjs +2 -5
- package/scripts/lib/dispatcher/cli.mjs +2 -1
- package/scripts/lib/ecosystem-wizard.mjs +2 -1
- package/scripts/lib/fetch-baseline.mjs +3 -8
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
- package/scripts/lib/instruction-budget-guard.mjs +186 -46
- package/scripts/lib/is-main-module.mjs +82 -0
- package/scripts/lib/locks/index.mjs +32 -25
- package/scripts/lib/maintenance-due-banner.mjs +69 -3
- package/scripts/lib/peer-discovery.mjs +2 -5
- package/scripts/lib/playwright-driver/runner.mjs +2 -1
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
- package/scripts/lib/rules-sync.mjs +2 -5
- package/scripts/lib/scope-echo.mjs +392 -7
- package/scripts/lib/session-close-backfill.mjs +58 -6
- package/scripts/lib/state-md.mjs +84 -3
- package/scripts/lib/sunset/walker.mjs +31 -4
- package/scripts/lib/tests-src-ratio.mjs +2 -6
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
- package/scripts/lib/user-invocable-skills.mjs +185 -0
- package/scripts/lib/validate/check-banner-parity.mjs +2 -2
- package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
- package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
- package/scripts/lib/validate/check-entry-guard.mjs +366 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
- package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
- package/scripts/lib/validate/check-skill-links.mjs +27 -6
- package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
- package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
- package/scripts/lib/validate/check-unwired-features.mjs +91 -7
- package/scripts/lib/validate/check-validator-registration.mjs +2 -2
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
- package/scripts/lib/validate-vendored-rules.mjs +35 -9
- package/scripts/lib/wave-transcript-tail.mjs +2 -2
- package/scripts/lock-reaper.mjs +2 -1
- package/scripts/materialize-wave-scope.mjs +87 -4
- package/scripts/migrate-sessions-jsonl.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +2 -3
- package/scripts/release.mjs +80 -35
- package/scripts/relocate-vault-corpus.mjs +2 -3
- package/scripts/repair-invalid-sessions.mjs +2 -2
- package/scripts/session-shape.mjs +2 -2
- package/scripts/site-numbers.mjs +35 -11
- package/scripts/sweep-expired-rules.mjs +216 -0
- package/scripts/validate-plugin.mjs +9 -0
- package/scripts/vault-consolidate.mjs +2 -2
- package/scripts/vault-mirror.mjs +2 -3
- package/scripts/wave-scope-binding.mjs +2 -3
- package/skills/_shared/bootstrap-gate.md +1 -1
- package/skills/_shared/monitor-patterns.md +1 -1
- package/skills/_shared/research-evidence.md +53 -0
- package/skills/_shared/state-ownership.md +3 -0
- package/skills/autopilot/SKILL.md +58 -4
- package/skills/bootstrap/SKILL.md +51 -1
- package/skills/brainstorm/SKILL.md +16 -0
- package/skills/claude-md-drift-check/checker.mjs +49 -11
- package/{commands/close.md → skills/close/SKILL.md} +9 -3
- package/skills/debug/SKILL.md +10 -0
- package/skills/discovery/SKILL.md +24 -1
- package/skills/discovery/probes-session.md +2 -2
- package/skills/dispatcher/SKILL.md +38 -7
- package/skills/eli5/SKILL.md +11 -0
- package/skills/eval/SKILL.md +14 -0
- package/skills/evolve/SKILL.md +8 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
- package/{commands/go.md → skills/go/SKILL.md} +9 -1
- package/skills/grill/SKILL.md +19 -0
- package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
- package/skills/hook-development/SKILL.md +46 -41
- package/skills/memory-cleanup/SKILL.md +7 -0
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +56 -1
- package/skills/persona-panel/persona-format.md +1 -1
- package/skills/plan/SKILL.md +28 -1
- package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
- package/skills/reconcile/SKILL.md +10 -0
- package/{commands/release.md → skills/release/SKILL.md} +16 -2
- package/skills/repo-audit/SKILL.md +7 -0
- package/skills/session-end/plan-verification.md +2 -2
- package/skills/session-plan/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +5 -4
- package/skills/session-start/phase-8-5-express-path.md +6 -6
- package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
- package/skills/spinout/SKILL.md +12 -1
- package/skills/sunset-review/SKILL.md +13 -0
- package/{commands/test.md → skills/test/SKILL.md} +10 -4
- package/skills/ux-grill/SKILL.md +19 -1
- package/skills/wave-executor/SKILL.md +7 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +17 -1
- package/commands/autopilot.md +0 -80
- package/commands/bootstrap.md +0 -56
- package/commands/brainstorm.md +0 -48
- package/commands/debug.md +0 -36
- package/commands/discovery.md +0 -32
- package/commands/dispatcher.md +0 -59
- package/commands/eli5.md +0 -33
- package/commands/eval.md +0 -28
- package/commands/evolve.md +0 -10
- package/commands/grill.md +0 -45
- package/commands/memory-cleanup.md +0 -26
- package/commands/persona-panel.md +0 -121
- package/commands/plan.md +0 -15
- package/commands/reconcile.md +0 -23
- package/commands/repo-audit.md +0 -24
- package/commands/spinout.md +0 -15
- package/commands/sunset-review.md +0 -27
- package/commands/ux-grill.md +0 -51
|
@@ -96,28 +96,25 @@
|
|
|
96
96
|
* triage of a per-session counter file
|
|
97
97
|
* (`.orchestrator/runtime/issue-budget/<hash>.json`, #1141) or in a
|
|
98
98
|
* transcript census of real create calls.
|
|
99
|
-
* - An `xargs`-driven create
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* xargs -
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* `hooks/pre-bash-
|
|
116
|
-
* `
|
|
117
|
-
*
|
|
118
|
-
* Pinned by `tests/hooks/vcs-create-matcher.test.mjs`. Revisit when an
|
|
119
|
-
* `xargs`-driven create shows up in a transcript census or in the overflow
|
|
120
|
-
* triage of a per-session counter file.
|
|
99
|
+
* - An `xargs`-driven create IS matched since #1289 — but LOCALLY, in
|
|
100
|
+
* {@link xargsDrivenRemainder}, never by changing what `resolveSegmentVerb`
|
|
101
|
+
* reports for `xargs`. That resolver still answers `verb: 'xargs'`, because
|
|
102
|
+
* `command-blocker.mjs` classes `xargs` as an INTERPRETER
|
|
103
|
+
* (`SHELL_EXEC_INTERPRETERS`) and unwrapping it there would LOOSEN the
|
|
104
|
+
* destructive-command guard the five positional consumers of that resolver
|
|
105
|
+
* share. Measured 2026-09-16 against this file — the four shapes that
|
|
106
|
+
* yielded 0 statements before now yield 1 each, flagged `bulk: true`:
|
|
107
|
+
*
|
|
108
|
+
* xargs glab issue create --title X → 1
|
|
109
|
+
* echo X | xargs -I% glab issue create --title % → 1
|
|
110
|
+
* seq 1 50 | xargs -I% gh api -X POST …/issues -f title=% → 1
|
|
111
|
+
* xargs -n1 glab issue create → 1
|
|
112
|
+
*
|
|
113
|
+
* A `bulk` statement is never CHARGED: the multiplicity comes from the
|
|
114
|
+
* stdin word list and is unknowable before the shell runs, so
|
|
115
|
+
* `hooks/pre-bash-issue-budget.mjs` treats it exactly like a loop body —
|
|
116
|
+
* deny in `strict`, report the undercount in `warn`. The remaining ceiling
|
|
117
|
+
* is the option grammar, named at {@link xargsDrivenRemainder}.
|
|
121
118
|
* - A paren glued to the verb (`(glab issue create …)`) is not reached: it
|
|
122
119
|
* lexes as the single word `(glab`. This is `command-blocker.mjs`'s own
|
|
123
120
|
* named ceiling on `COMMAND_POSITION_KEYWORDS` (#1145), inherited here
|
|
@@ -164,7 +161,14 @@ export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
|
|
|
164
161
|
function statementsOf(command) {
|
|
165
162
|
if (typeof command !== 'string' || command.length === 0) return [];
|
|
166
163
|
try {
|
|
167
|
-
|
|
164
|
+
const segments = splitChainSegments(tokenizeCommand(command));
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const segment of segments) {
|
|
167
|
+
out.push(segment);
|
|
168
|
+
const rest = xargsDrivenRemainder(segment);
|
|
169
|
+
if (rest !== null) out.push(rest);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
168
172
|
} catch {
|
|
169
173
|
// Fail OPEN, matching every other error path in the two consuming hooks:
|
|
170
174
|
// a lexer that throws must not wedge every Bash call. Worst case is a
|
|
@@ -173,6 +177,64 @@ function statementsOf(command) {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
/**
|
|
181
|
+
* xargs' own options, split by whether they consume a SEPARATE next token.
|
|
182
|
+
* Both the separated (`-n 1`) and the attached (`-n1`, `-I%`) spellings are
|
|
183
|
+
* handled — the attached form is what agents actually write.
|
|
184
|
+
*/
|
|
185
|
+
const XARGS_VALUE_FLAGS = new Set(['-I', '-i', '-n', '-P', '-L', '-s', '-E', '-d', '-a']);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The UTILITY xargs drives, as an additional statement — or `null` when the
|
|
189
|
+
* segment is not an `xargs` call, or nothing follows its options (#1289).
|
|
190
|
+
*
|
|
191
|
+
* ## Why here and not in the shared lexer
|
|
192
|
+
*
|
|
193
|
+
* `command-blocker.mjs` classes `xargs` as an INTERPRETER, deliberately NOT a
|
|
194
|
+
* transparent wrapper: unwrapping it there would resolve past it for the FIVE
|
|
195
|
+
* positional consumers of `resolveSegmentVerb` and LOOSEN the destructive-command
|
|
196
|
+
* guard. So the widening stays local to this matcher, where the only question is
|
|
197
|
+
* "does this command file issues?" — the shared verb resolution is untouched,
|
|
198
|
+
* and `resolveSegmentVerb` still reports `verb: 'xargs'`.
|
|
199
|
+
*
|
|
200
|
+
* The returned array carries a non-index `xargsDriven` marker so
|
|
201
|
+
* {@link findIssueCreateStatements} can flag the statement `bulk: true`: the
|
|
202
|
+
* multiplicity is set by the STDIN word list, which does not exist at hook time,
|
|
203
|
+
* so such a create can never be charged — only denied or reported.
|
|
204
|
+
*
|
|
205
|
+
* NAMED CEILING (BV-004): only the option spellings in `XARGS_VALUE_FLAGS` plus
|
|
206
|
+
* bare boolean/unknown short flags are skipped. A SEPARATED long form
|
|
207
|
+
* (`--max-args 5`) leaves its operand in verb position, so the remainder does not
|
|
208
|
+
* match a create shape and the call is missed — the pre-#1289 behaviour, i.e. an
|
|
209
|
+
* under-report, never a false deny. REVISIT TRIGGER: a separated-long-form xargs
|
|
210
|
+
* create in a transcript census.
|
|
211
|
+
*
|
|
212
|
+
* @param {Array<{ text: string, quoted: boolean }>} segment
|
|
213
|
+
* @returns {Array<{ text: string, quoted: boolean }>|null}
|
|
214
|
+
*/
|
|
215
|
+
function xargsDrivenRemainder(segment) {
|
|
216
|
+
if (segment.length === 0) return null;
|
|
217
|
+
const head = segment[0];
|
|
218
|
+
if (head.quoted || head.text.replace(/^.*\//, '') !== 'xargs') return null;
|
|
219
|
+
|
|
220
|
+
let i = 1;
|
|
221
|
+
while (i < segment.length) {
|
|
222
|
+
const tok = segment[i];
|
|
223
|
+
if (tok.quoted) break; // a quoted word is the utility, never an option
|
|
224
|
+
const text = tok.text;
|
|
225
|
+
if (text === '--') { i++; break; }
|
|
226
|
+
if (!text.startsWith('-') || text === '-') break;
|
|
227
|
+
if (XARGS_VALUE_FLAGS.has(text)) { i += 2; continue; } // separated: `-n 1`
|
|
228
|
+
if (XARGS_VALUE_FLAGS.has(text.slice(0, 2))) { i += 1; continue; } // attached: `-I%`
|
|
229
|
+
i += 1; // boolean (`-0`, `-r`, `-t`) or an unknown flag
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const rest = segment.slice(i);
|
|
233
|
+
if (rest.length === 0) return null;
|
|
234
|
+
rest.xargsDriven = true;
|
|
235
|
+
return rest;
|
|
236
|
+
}
|
|
237
|
+
|
|
176
238
|
/**
|
|
177
239
|
* `--help` short-circuits a cobra command (both `gh` and `glab` are cobra
|
|
178
240
|
* CLIs): the help text is printed and the command body never runs, so
|
|
@@ -609,13 +671,19 @@ export function findIssueCreateStatements(command) {
|
|
|
609
671
|
continue;
|
|
610
672
|
}
|
|
611
673
|
literalCatPaths ??= singleQuotedCatPaths(command);
|
|
612
|
-
|
|
674
|
+
const statement = {
|
|
613
675
|
shape,
|
|
614
676
|
tokens,
|
|
615
677
|
text: tokens.map((t) => t.text).join(' '),
|
|
616
678
|
...fieldsFromTokens(tokens, shape, literalCatPaths),
|
|
617
679
|
cwdChanged,
|
|
618
|
-
}
|
|
680
|
+
};
|
|
681
|
+
// `bulk` — the create files an UNKNOWN number of issues (#1289). Today the
|
|
682
|
+
// only source is an `xargs`-driven statement, whose multiplicity is the
|
|
683
|
+
// stdin word list; the key is present ONLY when true, so every existing
|
|
684
|
+
// consumer of this shape is unaffected.
|
|
685
|
+
if (tokens.xargsDriven === true) statement.bulk = true;
|
|
686
|
+
out.push(statement);
|
|
619
687
|
}
|
|
620
688
|
return out;
|
|
621
689
|
}
|
|
@@ -689,6 +757,124 @@ export function isIssueCreate(command) {
|
|
|
689
757
|
return matchVcsCreate(command)?.kind === 'issue';
|
|
690
758
|
}
|
|
691
759
|
|
|
760
|
+
/**
|
|
761
|
+
* EVERY issue-create STATEMENT that sits inside a LOOP BODY — one token array
|
|
762
|
+
* per implicated loop, `[]` when none (#1145, #1379).
|
|
763
|
+
*
|
|
764
|
+
* Returning the statements rather than a boolean is what lets the consuming hook
|
|
765
|
+
* bind the CAP EXEMPTION to the statement that CAUSED the bulk classification.
|
|
766
|
+
* Measured 2026-09-16 against `hooks/pre-bash-issue-budget.mjs` before this
|
|
767
|
+
* change, which classified the exemption on `statements[0]`:
|
|
768
|
+
*
|
|
769
|
+
* for i in 1 2 3; do glab issue create --title junk$i; done
|
|
770
|
+
* → DENY
|
|
771
|
+
* glab issue create --title "[Carryover] real"; for i in 1 2 3; do \
|
|
772
|
+
* glab issue create --title junk$i; done
|
|
773
|
+
* → ALLOW
|
|
774
|
+
*
|
|
775
|
+
* The second command files an unknowable number of UNTEMPLATED issues, lifted
|
|
776
|
+
* by an unrelated neighbour's exemption. That is `.claude/rules/guard-design.md`
|
|
777
|
+
* § "Widening a matcher without narrowing its bypass" (#1106 class) in the
|
|
778
|
+
* exemption lane instead of the bypass lane — same shape, same fix: judge the
|
|
779
|
+
* exemption on the statement the gate fired on, never on a sibling.
|
|
780
|
+
*
|
|
781
|
+
* Each element is the segment the loop-depth scan implicated, so the caller can
|
|
782
|
+
* rebuild its classification text exactly as {@link findIssueCreateStatements}
|
|
783
|
+
* does (`tokens.map((t) => t.text).join(' ')`).
|
|
784
|
+
*
|
|
785
|
+
* ## Why ALL of them, not the first (#1379)
|
|
786
|
+
*
|
|
787
|
+
* The 2026-09-16 scan below collected every candidate head but `return`ed on
|
|
788
|
+
* the first one found at `depth > 0`. Since the consuming hook classifies the
|
|
789
|
+
* cap exemption PER bulk statement, reporting one loop for a two-loop command
|
|
790
|
+
* means an EXEMPT first loop is the only loop judged and every later loop goes
|
|
791
|
+
* unexamined. Reproduced 2026-09-17 @ `9e8146b4` against the live hook in a
|
|
792
|
+
* strict-mode throwaway repo:
|
|
793
|
+
*
|
|
794
|
+
* for i in 1 2 3; do glab issue create --label carryover --title x$i; done;
|
|
795
|
+
* for j in 1 2 3; do glab issue create --title junk$j; done
|
|
796
|
+
* → ALLOW, ledger count=1 exempt=1 (must DENY)
|
|
797
|
+
*
|
|
798
|
+
* Same class as the exemption-binding fix this docblock already describes, one
|
|
799
|
+
* level up: judging the exemption on ONE of several bulk statements is the same
|
|
800
|
+
* defect as judging it on a sibling.
|
|
801
|
+
*
|
|
802
|
+
* @param {string} command
|
|
803
|
+
* @returns {Array<Array<{ text: string, quoted: boolean }>>} one entry per loop
|
|
804
|
+
*/
|
|
805
|
+
export function findLoopedIssueCreates(command) {
|
|
806
|
+
if (typeof command !== 'string' || command.length === 0) return [];
|
|
807
|
+
|
|
808
|
+
let tokens;
|
|
809
|
+
let segments;
|
|
810
|
+
try {
|
|
811
|
+
tokens = tokenizeCommand(command);
|
|
812
|
+
segments = splitChainSegments(tokens);
|
|
813
|
+
} catch {
|
|
814
|
+
return []; // fail OPEN, same posture as statementsOf
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// EVERY issue-create statement is a candidate, keyed by its HEAD token's
|
|
818
|
+
// object identity — the position in the raw stream at which the loop depth
|
|
819
|
+
// must be read.
|
|
820
|
+
//
|
|
821
|
+
// Until 2026-09-16 this considered only the FIRST create statement of ANY
|
|
822
|
+
// kind and then checked its `kind`, mirroring the pre-#1163 hook whose G3 was
|
|
823
|
+
// a single `isIssueCreate(command)` boolean. That mirror is obsolete and was
|
|
824
|
+
// a MISS: since #1163 the hook judges EVERY statement via
|
|
825
|
+
// {@link findIssueCreateStatements}, and the old first-only scan reported
|
|
826
|
+
// `false` for any command whose first create sits outside the loop. Measured
|
|
827
|
+
// 2026-09-16 against HEAD:
|
|
828
|
+
//
|
|
829
|
+
// glab issue create --title "[Carryover] real"; for i in 1 2 3; do \
|
|
830
|
+
// glab issue create --title junk$i; done → false (should be true)
|
|
831
|
+
// glab mr create --title m; for i in 1 2; do \
|
|
832
|
+
// glab issue create --title j; done → false (should be true)
|
|
833
|
+
// for i in 1 2 3; do glab issue create --title j; done → true
|
|
834
|
+
//
|
|
835
|
+
// The first shape files an unknowable number of issues and was ALLOWED by the
|
|
836
|
+
// budget gate. Scanning all candidates is the fail-CLOSED direction and no
|
|
837
|
+
// longer disagrees with the hook about which statements exist.
|
|
838
|
+
const createHeads = new Map();
|
|
839
|
+
for (const seg of segments) {
|
|
840
|
+
const shape = matchStatement(seg);
|
|
841
|
+
if (shape && shape.kind === 'issue') createHeads.set(seg[0], seg);
|
|
842
|
+
}
|
|
843
|
+
if (createHeads.size === 0) return [];
|
|
844
|
+
|
|
845
|
+
const kept = new Set(segments.flat());
|
|
846
|
+
const found = [];
|
|
847
|
+
let depth = 0;
|
|
848
|
+
for (const tok of tokens) {
|
|
849
|
+
// A create head at depth 0 is a create AFTER (or BEFORE) a loop, not inside
|
|
850
|
+
// one: `while x; do echo a; done; glab issue create` stays unimplicated.
|
|
851
|
+
if (createHeads.has(tok)) {
|
|
852
|
+
if (depth > 0) found.push(createHeads.get(tok));
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
if (tok.quoted || kept.has(tok)) continue; // an argument, or a separator
|
|
856
|
+
if (tok.text === 'do') depth += 1;
|
|
857
|
+
else if (tok.text === 'done' && depth > 0) depth -= 1;
|
|
858
|
+
}
|
|
859
|
+
return found;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The FIRST looped issue-create statement, or `null`.
|
|
864
|
+
*
|
|
865
|
+
* @deprecated since #1379 — use {@link findLoopedIssueCreates}, which reports
|
|
866
|
+
* EVERY implicated loop. A caller that classifies anything per loop (a cap
|
|
867
|
+
* exemption, a per-loop deny reason) is WRONG on this API for a command with
|
|
868
|
+
* two loops; kept only because this module has no `exports` map, so every
|
|
869
|
+
* export is a public entrypoint a consumer may already import.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} command
|
|
872
|
+
* @returns {Array<{ text: string, quoted: boolean }>|null}
|
|
873
|
+
*/
|
|
874
|
+
export function findLoopedIssueCreate(command) {
|
|
875
|
+
return findLoopedIssueCreates(command)[0] ?? null;
|
|
876
|
+
}
|
|
877
|
+
|
|
692
878
|
/**
|
|
693
879
|
* True when the matched issue-create statement sits inside a LOOP BODY, i.e.
|
|
694
880
|
* the command creates an unknown number of issues (#1145).
|
|
@@ -730,47 +916,16 @@ export function isIssueCreate(command) {
|
|
|
730
916
|
* to charging 1 for N. The unit test `tests/hooks/vcs-create-matcher.test.mjs`
|
|
731
917
|
* pins the coupling directly. Revisit if that set is ever narrowed.
|
|
732
918
|
*
|
|
919
|
+
* Boolean wrapper over {@link findLoopedIssueCreates} — kept as the named
|
|
920
|
+
* question for callers that only need the FACT (and for the sibling suites that
|
|
921
|
+
* pin it). A caller that must classify the cap exemption asks for the STATEMENT
|
|
922
|
+
* instead; see that function's docblock for why the distinction is load-bearing.
|
|
923
|
+
*
|
|
733
924
|
* @param {string} command
|
|
734
925
|
* @returns {boolean} false when the command has no issue-create statement at all
|
|
735
926
|
*/
|
|
736
927
|
export function isLoopedIssueCreate(command) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
let tokens;
|
|
740
|
-
let segments;
|
|
741
|
-
try {
|
|
742
|
-
tokens = tokenizeCommand(command);
|
|
743
|
-
segments = splitChainSegments(tokens);
|
|
744
|
-
} catch {
|
|
745
|
-
return false; // fail OPEN, same posture as statementsOf
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// The create statement's HEAD token, by object identity — the position in the
|
|
749
|
-
// raw stream at which the loop depth must be read.
|
|
750
|
-
//
|
|
751
|
-
// Deliberately the FIRST create statement of any kind, then a kind check —
|
|
752
|
-
// exactly what isIssueCreate() does via matchVcsCreate(). Searching for the
|
|
753
|
-
// first ISSUE create instead would diverge on `glab mr create … ; glab issue
|
|
754
|
-
// create …`: the hook's G3 would look at the mr, this at the issue, and the
|
|
755
|
-
// two would disagree about which statement they are judging.
|
|
756
|
-
let headTok = null;
|
|
757
|
-
for (const seg of segments) {
|
|
758
|
-
const shape = matchStatement(seg);
|
|
759
|
-
if (!shape) continue;
|
|
760
|
-
if (shape.kind === 'issue') headTok = seg[0];
|
|
761
|
-
break;
|
|
762
|
-
}
|
|
763
|
-
if (!headTok) return false;
|
|
764
|
-
|
|
765
|
-
const kept = new Set(segments.flat());
|
|
766
|
-
let depth = 0;
|
|
767
|
-
for (const tok of tokens) {
|
|
768
|
-
if (tok === headTok) return depth > 0;
|
|
769
|
-
if (tok.quoted || kept.has(tok)) continue; // an argument, or a separator
|
|
770
|
-
if (tok.text === 'do') depth += 1;
|
|
771
|
-
else if (tok.text === 'done' && depth > 0) depth -= 1;
|
|
772
|
-
}
|
|
773
|
-
return false;
|
|
928
|
+
return findLoopedIssueCreates(command).length > 0;
|
|
774
929
|
}
|
|
775
930
|
|
|
776
931
|
/**
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.2.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.2.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
package/hooks/on-session-end.mjs
CHANGED
|
@@ -618,8 +618,20 @@ async function main() {
|
|
|
618
618
|
// Dead-by-age relaxation (relaxDeadByAge/assumeDeadBeforeMs, #731 — used by the
|
|
619
619
|
// historical migration CLI) is NEVER passed here: a foreign lock that is live
|
|
620
620
|
// at hook-time is, by definition, a real active session, not stale history.
|
|
621
|
+
// `ownSessionIsEnding` (#1376) rides the SAME `isRecordedSession`
|
|
622
|
+
// attestation as `duration_ms` / `semantic_session_id` above, for the
|
|
623
|
+
// same reason: it is a claim made ON BEHALF of the recorded session, and
|
|
624
|
+
// a foreign terminating window that inherited this repo's
|
|
625
|
+
// `current-session.json` identity (#863 defect (b)) must never make it.
|
|
626
|
+
// With the attestation false the core keeps its blanket own-live-lock
|
|
627
|
+
// skip, so the #863 behaviour is preserved exactly where #863 applied.
|
|
621
628
|
try {
|
|
622
|
-
const res = await backfillAbandonedSession({
|
|
629
|
+
const res = await backfillAbandonedSession({
|
|
630
|
+
repoRoot: projectRoot,
|
|
631
|
+
sessionId,
|
|
632
|
+
semanticSessionId,
|
|
633
|
+
ownSessionIsEnding: isRecordedSession,
|
|
634
|
+
});
|
|
623
635
|
await emitBackfillOutcome('abandoned', res, { sessionId, semanticSessionId });
|
|
624
636
|
} catch { /* best-effort — never block teardown */ }
|
|
625
637
|
|
|
@@ -627,7 +639,7 @@ async function main() {
|
|
|
627
639
|
// above and to THIS session's own id: it reads STATE.md directly and
|
|
628
640
|
// repairs a PAST session whose `status: completed` was set (by hand or
|
|
629
641
|
// otherwise) without session-end's Phase 3.7 ever writing the matching
|
|
630
|
-
// sessions.jsonl record — the exact state `
|
|
642
|
+
// sessions.jsonl record — the exact state `skills/close/SKILL.md`'s
|
|
631
643
|
// Pre-Check then reads as "already finalized" forever after. Cheap
|
|
632
644
|
// no-op on the overwhelmingly common path (STATE.md status is
|
|
633
645
|
// 'active'/'paused'/'idle', or the record already exists).
|
package/hooks/on-stop.mjs
CHANGED
|
@@ -551,6 +551,39 @@ async function handleSubagentStop(input) {
|
|
|
551
551
|
} catch { /* probe failed — omit rather than assert `false` */ }
|
|
552
552
|
|
|
553
553
|
try {
|
|
554
|
+
// `sidecar_missing` — the DENOMINATOR for every meta-derived field below
|
|
555
|
+
// (#1289 Befund 2). `agent_type_meta` and `tool_use_id` come from the same
|
|
556
|
+
// read, so they co-occur perfectly (measured 2026-09-16 over
|
|
557
|
+
// `.orchestrator/metrics/events.jsonl`: 15.291 `orchestrator.agent.stopped`
|
|
558
|
+
// records, 542 with `agent_type_meta`, 542 with `tool_use_id`,
|
|
559
|
+
// both/only-meta/only-tool = 542/0/0). That made the low presence rate look
|
|
560
|
+
// like a producer defect in the KEY, when 1000/1000 sidecars on this host
|
|
561
|
+
// carry `agentType`. This flag makes "no sidecar found" visible in the
|
|
562
|
+
// record instead of indistinguishable from "sidecar found, field absent".
|
|
563
|
+
//
|
|
564
|
+
// WHAT THIS KEY IS NOT: a sidecar-LOOKUP failure rate. Re-measured
|
|
565
|
+
// 2026-09-16 over 15.457 `orchestrator.agent.stopped` records (5086
|
|
566
|
+
// `transcript_found:false` vs 553 `true`), the false mass is the #939/#949
|
|
567
|
+
// PHANTOM-STOP class — no subagent ever existed, so there is nothing for
|
|
568
|
+
// the lookup to find:
|
|
569
|
+
// - 0 of 5077 distinct false `agent_id`s have a sidecar ANYWHERE on this
|
|
570
|
+
// host (`find ~/.claude/projects -path '*/subagents/agent-*.jsonl'` →
|
|
571
|
+
// 8626 ids), and 0 have a `SubagentStart` record (2176 starts in
|
|
572
|
+
// `.orchestrator/metrics/subagents.jsonl`);
|
|
573
|
+
// - 0 records carrying a valid `agent_id` got a null resolver result;
|
|
574
|
+
// - the two classes are perfectly bimodal on the type field:
|
|
575
|
+
// `false` + no `agent` 5086, `true` + typed 553, off-diagonal 0.
|
|
576
|
+
// `agent` (the harness `agent_type`) is therefore the ready-made
|
|
577
|
+
// discriminator — no further key is needed. Do NOT "repair" the derivation
|
|
578
|
+
// in hooks/_lib/subagent-paths.mjs to chase this rate: it already resolves
|
|
579
|
+
// 553/553 of the real stops, and a widened one would only start resolving
|
|
580
|
+
// onto a FOREIGN agent's sidecar. This misreading has now cost three
|
|
581
|
+
// investigations (#939, the 2026-08-11 ledger re-run, #1289 Befund 2).
|
|
582
|
+
//
|
|
583
|
+
// Present ONLY when true, like every other optional key in this payload;
|
|
584
|
+
// its own probe is inside this try, so a failed `existsSync` omits it and
|
|
585
|
+
// nothing else (a telemetry fault must never change a decision).
|
|
586
|
+
if (!existsSync(metaPath)) payload.sidecar_missing = true;
|
|
554
587
|
// One small read, one parse, two fields. `description` is operator prose
|
|
555
588
|
// and is deliberately NOT carried: this payload also travels over the
|
|
556
589
|
// optional Clank webhook unredacted.
|
|
@@ -565,7 +598,16 @@ async function handleSubagentStop(input) {
|
|
|
565
598
|
payload.tool_use_id = meta.toolUseId.trim();
|
|
566
599
|
}
|
|
567
600
|
// A SECOND witness for the type — never merged into `agent`, so the
|
|
568
|
-
// empty-`agent_type` rate stays measurable.
|
|
601
|
+
// empty-`agent_type` rate stays measurable. NO FALLBACK MERGE, deliberately:
|
|
602
|
+
// `payload.agent` already carries the harness's `input.agent_type`, and
|
|
603
|
+
// filling one from the other would erase exactly the signal this pair
|
|
604
|
+
// exists to produce.
|
|
605
|
+
//
|
|
606
|
+
// REPORTING RULE: the presence rate of this key is only meaningful against
|
|
607
|
+
// the events whose SIDECAR WAS FOUND — "present on N of M events without
|
|
608
|
+
// `sidecar_missing`", never N of all `orchestrator.agent.stopped` records.
|
|
609
|
+
// Measured against all events the rate reads as a producer defect in this
|
|
610
|
+
// block; against its real denominator it is a lookup-failure rate.
|
|
569
611
|
if (typeof meta?.agentType === 'string' && AGENT_TYPE_META_RE.test(meta.agentType.trim())) {
|
|
570
612
|
payload.agent_type_meta = meta.agentType.trim();
|
|
571
613
|
}
|
|
@@ -1120,6 +1120,9 @@ async function main() {
|
|
|
1120
1120
|
// string compare then reads false and silently no-ops the whole scope-detector
|
|
1121
1121
|
// under a symlinked `$CLAUDE_PLUGIN_ROOT`. realpath'ing both sides also survives
|
|
1122
1122
|
// `--preserve-symlinks` (where import.meta.url stays symlinked instead).
|
|
1123
|
+
// BV-004 ceiling: kept inline — hooks avoid importing scripts/lib on the hot
|
|
1124
|
+
// path; the canonical predicate is scripts/lib/is-main-module.mjs. Revisit if the
|
|
1125
|
+
// hook-import-set (hooks/_lib/hook-import-set.json) ever admits scripts/lib here.
|
|
1123
1126
|
function invokedAsScript() {
|
|
1124
1127
|
const entry = process.argv[1];
|
|
1125
1128
|
if (!entry) return false;
|
|
@@ -778,6 +778,9 @@ async function main() {
|
|
|
778
778
|
// under a symlinked plugin install) while `import.meta.url` is realpath-resolved
|
|
779
779
|
// by node's default loader, so BOTH sides are realpath'd.
|
|
780
780
|
// ---------------------------------------------------------------------------
|
|
781
|
+
// BV-004 ceiling: kept inline — hooks avoid importing scripts/lib on the hot
|
|
782
|
+
// path; the canonical predicate is scripts/lib/is-main-module.mjs. Revisit if the
|
|
783
|
+
// hook-import-set (hooks/_lib/hook-import-set.json) ever admits scripts/lib here.
|
|
781
784
|
function invokedAsScript() {
|
|
782
785
|
const entry = process.argv[1];
|
|
783
786
|
if (!entry) return false;
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
|
|
54
54
|
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
55
55
|
import { readJson } from '../scripts/lib/common.mjs';
|
|
56
|
-
import { findIssueCreateStatements,
|
|
56
|
+
import { findIssueCreateStatements, findLoopedIssueCreates } from './_lib/vcs-create-matcher.mjs';
|
|
57
57
|
import {
|
|
58
58
|
loadIssueBudgetConfig,
|
|
59
59
|
resolveIssueBudgetSessionId,
|
|
@@ -187,15 +187,39 @@ function resolveToolCallId(input) {
|
|
|
187
187
|
* entry #N … nothing is lost", which would be false here — an uncountable bulk
|
|
188
188
|
* request is not parked, it is handed back whole.
|
|
189
189
|
*
|
|
190
|
+
* ## Why the lane is a parameter (#1379)
|
|
191
|
+
*
|
|
192
|
+
* Until 2026-09-17 this text said "sits inside a shell loop body (`do … done`)"
|
|
193
|
+
* on BOTH lanes, so the `xargs` deny (`echo b | xargs -I% glab issue create
|
|
194
|
+
* --title junk%` — no loop anywhere) sent the operator looking for a loop the
|
|
195
|
+
* command does not have. Only the lane SENTENCE varies; every other line is
|
|
196
|
+
* byte-identical across lanes, and tests pin them.
|
|
197
|
+
*
|
|
190
198
|
* @param {{ "max-per-session": number }} config
|
|
199
|
+
* @param {{ lane?: "loop"|"xargs"|"mixed" }} [opts]
|
|
191
200
|
* @returns {string}
|
|
192
201
|
*/
|
|
193
|
-
function formatLoopDenyReason(config) {
|
|
202
|
+
function formatLoopDenyReason(config, { lane = 'loop' } = {}) {
|
|
203
|
+
const LANE_SENTENCES = {
|
|
204
|
+
loop: [
|
|
205
|
+
'The `issue create` call sits inside a shell loop body (`do … done`), so the cap cannot',
|
|
206
|
+
'charge it honestly: the word list is expanded by the shell AFTER this hook runs, so',
|
|
207
|
+
'`for i in $(seq 1 50)` would file 50 issues against a count of 1.',
|
|
208
|
+
],
|
|
209
|
+
xargs: [
|
|
210
|
+
'The `issue create` call is driven by `xargs`, so the cap cannot charge it honestly: the',
|
|
211
|
+
'word list arrives on stdin AFTER this hook runs, so `seq 1 50 | xargs` would file 50',
|
|
212
|
+
'issues against a count of 1.',
|
|
213
|
+
],
|
|
214
|
+
mixed: [
|
|
215
|
+
'The `issue create` calls are driven by a shell loop body (`do … done`) AND by `xargs`,',
|
|
216
|
+
'so the cap cannot charge them honestly: both word lists are produced AFTER this hook',
|
|
217
|
+
'runs, so `seq 1 50 | xargs` would file 50 issues against a count of 1.',
|
|
218
|
+
],
|
|
219
|
+
};
|
|
194
220
|
return [
|
|
195
221
|
'issue-budget: this command creates an UNKNOWN number of issues — refusing to guess.',
|
|
196
|
-
|
|
197
|
-
'charge it honestly: the word list is expanded by the shell AFTER this hook runs, so',
|
|
198
|
-
'`for i in $(seq 1 50)` would file 50 issues against a count of 1.',
|
|
222
|
+
...(LANE_SENTENCES[lane] ?? LANE_SENTENCES.loop),
|
|
199
223
|
'',
|
|
200
224
|
'Nothing was parked as overflow, because nothing is lost: re-issue the create calls as',
|
|
201
225
|
'SEPARATE commands and each one is counted normally against the cap',
|
|
@@ -302,18 +326,59 @@ async function main() {
|
|
|
302
326
|
const config = loadIssueBudgetConfig(projectDir);
|
|
303
327
|
if (config.mode === 'off') return emitAllow();
|
|
304
328
|
|
|
305
|
-
// G3b — bulk creation whose multiplicity is not computable (#1145).
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
329
|
+
// G3b — bulk creation whose multiplicity is not computable (#1145). Two
|
|
330
|
+
// sources, one policy: a LOOP BODY (#1145) and an `xargs`-driven create
|
|
331
|
+
// (#1289), where the word list arrives on stdin. Both file N issues for one
|
|
332
|
+
// statement, so both are denied rather than charged 1 — the fix had to land
|
|
333
|
+
// here and not only in `isLoopedIssueCreate`, because G3 above short-circuits
|
|
334
|
+
// on `statements.length === 0` and an xargs create used to produce zero
|
|
335
|
+
// statements, so no loop-side fix could ever run.
|
|
336
|
+
//
|
|
337
|
+
// THE INVARIANT (rewritten 2026-09-16): the exemption is classified on the
|
|
338
|
+
// statement that CAUSED the bulk classification — never on `statements[0]`,
|
|
339
|
+
// and never on the whole command. A `[Carryover]` create standing NEXT TO an
|
|
340
|
+
// uncountable one is an unrelated neighbour and must not lift the deny; a
|
|
341
|
+
// `[Carryover]` create that IS the bulk statement keeps its documented
|
|
342
|
+
// unconditional pass (session-end's "those are never deferred" promise, which
|
|
343
|
+
// holds inside a loop too). Measured 2026-09-16 against the previous
|
|
344
|
+
// `statements[0]` binding, both lanes ALLOW where they must DENY:
|
|
345
|
+
// glab issue create --title "[Carryover] real"; echo X | xargs -I% glab issue create --title %
|
|
346
|
+
// glab issue create --title "[Carryover] real"; for i in 1 2 3; do glab issue create --title junk$i; done
|
|
347
|
+
// Same class as the bypass-scoping regression on `matchesBypass` — a matcher
|
|
348
|
+
// widened per statement while its exemption stayed whole-command
|
|
349
|
+
// (`.claude/rules/guard-design.md` § "Widening a matcher without narrowing
|
|
350
|
+
// its bypass", #1106).
|
|
351
|
+
//
|
|
352
|
+
// Fail-CLOSED when a command carries SEVERAL bulk statements and only some are
|
|
353
|
+
// exempt: `some()` over the non-exempt ones denies, because the command as a
|
|
354
|
+
// whole still files an uncountable number of untemplated issues.
|
|
355
|
+
//
|
|
356
|
+
// EVERY loop, not the first (#1379): the loop lane used to contribute at most
|
|
357
|
+
// ONE entry here, so an exempt FIRST loop was the only loop classified and
|
|
358
|
+
// every later loop went unjudged. Reproduced 2026-09-17 @ `9e8146b4`:
|
|
359
|
+
// for i in 1 2 3; do glab issue create --label carryover --title x$i; done;
|
|
360
|
+
// for j in 1 2 3; do glab issue create --title junk$j; done → ALLOW (count=1)
|
|
361
|
+
const bulkEntries = [
|
|
362
|
+
...findLoopedIssueCreates(command).map((tokens) => ({
|
|
363
|
+
lane: 'loop',
|
|
364
|
+
text: tokens.map((t) => t.text).join(' '),
|
|
365
|
+
})),
|
|
366
|
+
...statements.filter((s) => s.bulk).map((s) => ({ lane: 'xargs', text: s.text })),
|
|
367
|
+
];
|
|
368
|
+
const uncountableEntries = bulkEntries.filter((e) => !classifyExemption(e.text).exempt);
|
|
369
|
+
const uncountableBulk = uncountableEntries.length > 0;
|
|
370
|
+
// ONE lane resolution for BOTH reports (#1379 follow-up). The deny already
|
|
371
|
+
// named the lane it fired on; the `warn` undercount notice at the bottom of
|
|
372
|
+
// main() said "inside a loop body" unconditionally, so the xargs lane — where
|
|
373
|
+
// no loop exists anywhere in the command — sent the operator looking for one.
|
|
374
|
+
// Resolved here rather than twice, so the two reports can never disagree.
|
|
375
|
+
const uncountableLanes = new Set(uncountableEntries.map((e) => e.lane));
|
|
376
|
+
const uncountableLane =
|
|
377
|
+
uncountableLanes.size > 1 ? 'mixed' : ([...uncountableLanes][0] ?? 'loop');
|
|
313
378
|
if (uncountableBulk && config.mode === 'strict') {
|
|
314
379
|
// Nothing is charged and nothing is parked — the command is handed back
|
|
315
380
|
// whole, which is what makes unrolling it the correct next action.
|
|
316
|
-
return emitDeny(formatLoopDenyReason(config));
|
|
381
|
+
return emitDeny(formatLoopDenyReason(config, { lane: uncountableLane }));
|
|
317
382
|
}
|
|
318
383
|
|
|
319
384
|
const sessionId = await resolveSessionId(input, projectDir);
|
|
@@ -401,11 +466,32 @@ async function main() {
|
|
|
401
466
|
// to name the undercount out loud — a silent 1-for-N is the exact failure the
|
|
402
467
|
// deny above exists to prevent, and `warn` must not reintroduce it quietly.
|
|
403
468
|
// emitWarn, not stderr: under exit 0 stderr reaches only the debug log (#916).
|
|
404
|
-
|
|
469
|
+
//
|
|
470
|
+
// GATED ON THE BULK STATEMENTS, NEVER ON THE LAST VERDICT (2026-09-17). The
|
|
471
|
+
// condition used to read `verdict.decision === 'allow'`, i.e. the verdict of
|
|
472
|
+
// the LAST statement of the chain — so one exempt statement written AFTER an
|
|
473
|
+
// uncountable one silenced the notice the two paragraphs above declare
|
|
474
|
+
// mandatory. Reproduced through this hook binary in `mode: warn`:
|
|
475
|
+
// for i in 1 2; do glab issue create --title j$i; done; \
|
|
476
|
+
// glab issue create --title "[Carryover] z"
|
|
477
|
+
// → stdout EMPTY, ledger count=1 exempt=1 (the trailing exempt statement is
|
|
478
|
+
// an unrelated neighbour, exactly as in G3b's own invariant); without it,
|
|
479
|
+
// the identical loop reported the UNDERCOUNT.
|
|
480
|
+
// Reaching this point already means no statement blocked and none warned
|
|
481
|
+
// (both branches above return), so the uncountable bulk statement — non-exempt
|
|
482
|
+
// by construction of `uncountableEntries` — was charged as 1. The two negated
|
|
483
|
+
// conditions are kept explicit so a future reordering of those branches cannot
|
|
484
|
+
// turn this back into a report about the wrong statement.
|
|
485
|
+
if (uncountableBulk && !blocked && !verdicts.some((v) => v.decision === 'warn')) {
|
|
486
|
+
const LANE_PHRASES = {
|
|
487
|
+
loop: 'inside a loop body',
|
|
488
|
+
xargs: 'driven by `xargs`',
|
|
489
|
+
mixed: 'inside a loop body and by `xargs`',
|
|
490
|
+
};
|
|
405
491
|
return emitWarn(
|
|
406
|
-
`pre-bash-issue-budget: bulk create
|
|
492
|
+
`pre-bash-issue-budget: bulk create ${LANE_PHRASES[uncountableLane]} charged as 1 ` +
|
|
407
493
|
`(${verdict.count}/${verdict.max}) — the real number of issues this files is not ` +
|
|
408
|
-
`knowable before the
|
|
494
|
+
`knowable before the word list is expanded, so the count is an UNDERCOUNT. ` +
|
|
409
495
|
`Set \`issue-budget.mode: strict\` to deny this shape instead.`,
|
|
410
496
|
);
|
|
411
497
|
}
|