session-orchestrator 3.22.0 → 3.24.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/remote-offload/SKILL.md +13 -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 +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- 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 +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- 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 +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -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 +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- 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/hooks/subagent-telemetry.mjs +11 -26
- 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 +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- 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/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- 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/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- 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/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- 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 +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- 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 +517 -18
- 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 +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- 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 +220 -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-canonical.mjs +446 -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/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- 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 +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- 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-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- 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-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- 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/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- 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 +20 -9
- 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 +54 -17
- 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 +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -4,41 +4,203 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Extracted from `hooks/pre-bash-templates-first.mjs` (#519) so the
|
|
6
6
|
* issue-budget hook (`hooks/pre-bash-issue-budget.mjs`) reuses the SAME
|
|
7
|
-
*
|
|
7
|
+
* matcher + argument-boundary semantics instead of maintaining a second,
|
|
8
8
|
* silently-diverging copy. Both hooks share the PreToolUse Bash matcher and
|
|
9
9
|
* run sequentially, so a divergence here would mean one hook gates a command
|
|
10
10
|
* the other waves through.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* pulling the scripts/lib layer in.
|
|
12
|
+
* ## Statement splitting (#1106) — why this is no longer a single regex
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* Until #1106 the matcher was one regex anchored at the START of the whole
|
|
15
|
+
* command string (`/^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/`). Every
|
|
16
|
+
* shape that does not BEGIN with the CLI name was therefore invisible, and the
|
|
17
|
+
* most common agent shape — a `cd` into the repo, then the create call — is
|
|
18
|
+
* exactly such a shape. Measured 2026-08-23 in a live session: four issues were
|
|
19
|
+
* created as `cd <repo>\nglab issue create …` and the session's issue-budget
|
|
20
|
+
* counter file recorded `count: 0` (then the flat
|
|
21
|
+
* `.orchestrator/runtime/issue-budget.json`; one file per session under
|
|
22
|
+
* `.orchestrator/runtime/issue-budget/<hash>.json` since #1141). The
|
|
23
|
+
* `issue-budget` cap was not circumvented — for that command form it had never
|
|
24
|
+
* existed.
|
|
25
|
+
*
|
|
26
|
+
* The fix is to split the command into SHELL STATEMENTS and test each one, so
|
|
27
|
+
* a create call anywhere in a chain is seen. The hard part is not the splitting
|
|
28
|
+
* but the false-positive edge: `echo "glab issue create"`, a `#`-comment, and
|
|
29
|
+
* `grep -rn "glab issue create" docs/` all contain the literal words and must
|
|
30
|
+
* still NOT match. Naive splitting on `\n` / `&&` / `;` / `||` gets those wrong
|
|
31
|
+
* whenever a separator or a create-shaped line sits inside a quoted string or a
|
|
32
|
+
* here-doc body.
|
|
33
|
+
*
|
|
34
|
+
* So the splitting is delegated to `scripts/lib/command-blocker.mjs`
|
|
35
|
+
* (`tokenizeCommand` + `splitChainSegments`) — the lexer the destructive-command
|
|
36
|
+
* guard already runs on every Bash call. It carries quote state, `#`-comments,
|
|
37
|
+
* here-doc bodies, line continuations, arithmetic `<<`, and newline-as-separator,
|
|
38
|
+
* each of which was a measured bypass of that guard at some point. Writing a
|
|
39
|
+
* second shell splitter here would re-open every one of those holes in a file
|
|
40
|
+
* whose entire reason to exist is "no second, silently-diverging copy".
|
|
41
|
+
*
|
|
42
|
+
* That import is a deliberate revision of this file's former "ZERO IMPORTS by
|
|
43
|
+
* design" note: the leaf property bought nothing that a shared lexer does not,
|
|
44
|
+
* and the cost is one `node:path`-only module (~2 ms import, no side effects).
|
|
45
|
+
*
|
|
46
|
+
* LOAD-BEARING semantics (contract):
|
|
47
|
+
* MATCH: a SHELL STATEMENT whose RESOLVED VERB is `gh|glab` and whose two
|
|
48
|
+
* following tokens are `pr|mr|issue` + `create|new`, anywhere in
|
|
49
|
+
* the command chain. Statements are separated by unquoted `;` `&&`
|
|
50
|
+
* `||` `|` `&` and newline, and their command-position reserved
|
|
51
|
+
* words (`do`, `then`, `{`, …) are stripped by the lexer (#1145),
|
|
52
|
+
* so a create inside a loop body, a brace group, an `if` branch or
|
|
53
|
+
* a spaced subshell is a statement like any other. "Resolved verb"
|
|
54
|
+
* means: after leading `VAR=value` assignments, after transparent
|
|
55
|
+
* process wrappers (`nohup`, `command`, `sudo`, `env`, `timeout`,
|
|
56
|
+
* `xargs`, `exec`), and basename-normalised, so an absolute path
|
|
57
|
+
* (`/opt/homebrew/bin/glab`) resolves to `glab` (#1145).
|
|
58
|
+
* NO MATCH: the words inside a quoted string, a `#` comment, or a here-doc
|
|
59
|
+
* body (they are data, not a command); a single token that merely
|
|
60
|
+
* CONTAINS the three words (`"glab issue create"` runs a binary of
|
|
61
|
+
* that literal name, it is not a create call); edit operations
|
|
62
|
+
* (`gh pr edit`) — deliberately out of scope per the #519 PRD § 2;
|
|
63
|
+
* a create statement carrying `--help`, which prints help and
|
|
64
|
+
* creates nothing (#1145 — see {@link hasHelpFlag}).
|
|
65
|
+
*
|
|
66
|
+
* ## Verb resolution (#1145) — why the head is no longer `tokens.slice(0, 3)`
|
|
67
|
+
*
|
|
68
|
+
* #1106 made the matcher see every STATEMENT; it still read each statement's
|
|
69
|
+
* head as the raw first three tokens. So a statement whose first token is not
|
|
70
|
+
* literally `gh`/`glab` stayed invisible even when the shell would run exactly
|
|
71
|
+
* that binary. Measured 2026-08-26 against this file on `main`:
|
|
72
|
+
*
|
|
73
|
+
* nohup glab issue create --title g → isIssueCreate false
|
|
74
|
+
* /opt/homebrew/bin/glab issue create … → isIssueCreate false
|
|
75
|
+
* GITLAB_HOST=y glab issue create … → isIssueCreate false
|
|
76
|
+
*
|
|
77
|
+
* None of those is a segmentation defect — `splitChainSegments` already yields
|
|
78
|
+
* the whole statement, and `resolveSegmentVerb` already resolves all three
|
|
79
|
+
* (`nohup`/`command`/`sudo`/`env` are transparent wrappers in `WRAPPER_UNWRAP`,
|
|
80
|
+
* an absolute path is basename-normalised, and leading `VAR=value` assignments
|
|
81
|
+
* are skipped). The matcher simply never asked. It now does, via the same call
|
|
82
|
+
* shape the two sibling consumers use — `hooks/pre-bash-destructive-guard.mjs`
|
|
83
|
+
* (`parseRmTargets`, `commandHasRecursiveForceRm`) and
|
|
84
|
+
* `scripts/lib/scope-gate.mjs` (`extractBashWriteTargets`): split, then
|
|
85
|
+
* `resolveSegmentVerb`, then read the head at the RESOLVED index.
|
|
86
|
+
*
|
|
87
|
+
* ## Named ceilings (BV-004)
|
|
88
|
+
*
|
|
89
|
+
* - A create call reached only through a command substitution
|
|
90
|
+
* (`x=$(glab issue create …)`) is NOT matched: the substitution body never
|
|
91
|
+
* becomes a segment of its own. Unchanged since before #1106. Likewise a
|
|
92
|
+
* create carried as an interpreter PAYLOAD (`bash -c 'glab issue create'`)
|
|
93
|
+
* — `resolveSegmentVerb` reports the payload string, but this matcher does
|
|
94
|
+
* not recurse into it. Revisit either if the shape shows up in the overflow
|
|
95
|
+
* triage of a per-session counter file
|
|
96
|
+
* (`.orchestrator/runtime/issue-budget/<hash>.json`, #1141) or in a
|
|
97
|
+
* transcript census of real create calls.
|
|
98
|
+
* - A paren glued to the verb (`(glab issue create …)`) is not reached: it
|
|
99
|
+
* lexes as the single word `(glab`. This is `command-blocker.mjs`'s own
|
|
100
|
+
* named ceiling on `COMMAND_POSITION_KEYWORDS` (#1145), inherited here
|
|
101
|
+
* rather than re-patched — peeling the paren belongs in the lexer, whose
|
|
102
|
+
* tokens five consumers read positionally.
|
|
103
|
+
* - {@link matchesBypass} still compares from token index 0, NOT from the
|
|
104
|
+
* resolved verb index. So a wrapped create (`nohup gh pr create --dry-run`)
|
|
105
|
+
* is gated even when `gh pr create --dry-run` is a bypass entry. That is
|
|
106
|
+
* the fail-CLOSED direction of this widening and is deliberate: making an
|
|
107
|
+
* operator escape hatch reachable through a wrapper is the one change here
|
|
108
|
+
* that would LOOSEN a gate. Revisit only if an operator reports a live
|
|
109
|
+
* bypass entry going dead on a wrapped command.
|
|
21
110
|
*/
|
|
22
111
|
|
|
112
|
+
import {
|
|
113
|
+
tokenizeCommand,
|
|
114
|
+
splitChainSegments,
|
|
115
|
+
resolveSegmentVerb,
|
|
116
|
+
} from '../../scripts/lib/command-blocker.mjs';
|
|
117
|
+
|
|
23
118
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
119
|
+
* The accepted create shape, applied to the first THREE tokens of a shell
|
|
120
|
+
* statement (joined by single spaces — the lexer has already stripped quoting
|
|
121
|
+
* and collapsed whitespace).
|
|
122
|
+
*
|
|
123
|
+
* Kept as the single source of truth for the accepted vocabulary — the part
|
|
124
|
+
* most likely to drift — rather than being spread across three token
|
|
125
|
+
* comparisons. The leading `^\s*` and the trailing `\b` are retained so the
|
|
126
|
+
* regex still describes the same shape it did before #1106 (`created` / `news`
|
|
127
|
+
* do not match), and so a caller holding the old export still gets the old
|
|
128
|
+
* single-statement behaviour.
|
|
28
129
|
*/
|
|
29
130
|
export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
|
|
30
131
|
|
|
31
132
|
/**
|
|
32
|
-
*
|
|
133
|
+
* Split a raw shell command into statements, each as a token array.
|
|
134
|
+
*
|
|
135
|
+
* Thin, named wrapper over the shared lexer so the two call sites below (and
|
|
136
|
+
* any future one) cannot disagree about what "a statement" means.
|
|
33
137
|
*
|
|
34
138
|
* @param {string} command
|
|
139
|
+
* @returns {Array<Array<{ text: string, quoted: boolean }>>}
|
|
140
|
+
*/
|
|
141
|
+
function statementsOf(command) {
|
|
142
|
+
if (typeof command !== 'string' || command.length === 0) return [];
|
|
143
|
+
try {
|
|
144
|
+
return splitChainSegments(tokenizeCommand(command));
|
|
145
|
+
} catch {
|
|
146
|
+
// Fail OPEN, matching every other error path in the two consuming hooks:
|
|
147
|
+
// a lexer that throws must not wedge every Bash call. Worst case is a
|
|
148
|
+
// missed enforcement.
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `--help` short-circuits a cobra command (both `gh` and `glab` are cobra
|
|
155
|
+
* CLIs): the help text is printed and the command body never runs, so
|
|
156
|
+
* `glab issue create --help` — the exact call an agent makes to discover the
|
|
157
|
+
* flag names BEFORE filing — creates nothing. Counting it charged the session
|
|
158
|
+
* cap for a non-creation and, at the boundary, denied the next real issue.
|
|
159
|
+
* This is the narrowing that pairs with the #1145 widening.
|
|
160
|
+
*
|
|
161
|
+
* NAMED CEILING (BV-004): only the LONG form is honoured. `-h` is cobra's
|
|
162
|
+
* DEFAULT help shorthand but a subcommand may rebind it, and a rebound `-h`
|
|
163
|
+
* would turn this narrowing into a cap bypass on a command that really does
|
|
164
|
+
* create. `--help` is reserved by cobra and cannot be rebound, so it is safe
|
|
165
|
+
* where `-h` is not. Revisit if `gh`/`glab` ever ship a create subcommand
|
|
166
|
+
* whose `--help` is not the help flag.
|
|
167
|
+
*
|
|
168
|
+
* Quoted tokens do not count: `--title "run with --help"` is a title.
|
|
169
|
+
*
|
|
170
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
171
|
+
* @param {number} from — first index AFTER the three head words
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function hasHelpFlag(tokens, from) {
|
|
175
|
+
for (let i = from; i < tokens.length; i++) {
|
|
176
|
+
const t = tokens[i];
|
|
177
|
+
if (!t.quoted && t.text === '--help') return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Test one READING of a statement (a resolved verb plus the two tokens that
|
|
184
|
+
* follow it) against {@link CREATE_REGEX}.
|
|
185
|
+
*
|
|
186
|
+
* The three head words are checked for embedded whitespace first. Without that
|
|
187
|
+
* guard, `"glab issue create" --title x` — ONE token that happens to contain
|
|
188
|
+
* all three words, i.e. an attempt to run a binary literally named
|
|
189
|
+
* `glab issue create` — would join back into a matching head. The lexer strips
|
|
190
|
+
* quotes, so token boundaries are the only remaining evidence that the words
|
|
191
|
+
* arrived as three separate arguments.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} verb — basename-normalised verb from resolveSegmentVerb
|
|
194
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens — the whole statement
|
|
195
|
+
* @param {number} index — token index the verb resolved to
|
|
35
196
|
* @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
|
|
36
|
-
* `null` when the command is not a `gh`/`glab` create/new invocation.
|
|
37
197
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
198
|
+
function matchReading(verb, tokens, index) {
|
|
199
|
+
const head = [verb, tokens[index + 1]?.text, tokens[index + 2]?.text];
|
|
200
|
+
if (head.some((t) => typeof t !== 'string' || /\s/.test(t))) return null;
|
|
201
|
+
const m = head.join(' ').match(CREATE_REGEX);
|
|
41
202
|
if (!m) return null;
|
|
203
|
+
if (hasHelpFlag(tokens, index + 3)) return null;
|
|
42
204
|
return {
|
|
43
205
|
host: m[1] === 'gh' ? 'github' : 'gitlab',
|
|
44
206
|
kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
|
|
@@ -46,6 +208,68 @@ export function matchVcsCreate(command) {
|
|
|
46
208
|
};
|
|
47
209
|
}
|
|
48
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Test one statement's token array against {@link CREATE_REGEX}, reading the
|
|
213
|
+
* head at the RESOLVED verb index rather than at token 0 (#1145).
|
|
214
|
+
*
|
|
215
|
+
* BOTH readings of an ambiguous segment are judged. `resolveSegmentVerb`
|
|
216
|
+
* returns an `alt` reading whenever it skipped an unknown wrapper dash-flag
|
|
217
|
+
* that might have taken a value (`env -Q x glab issue create` — is `x` the
|
|
218
|
+
* verb, or `-Q`'s operand?). Its own contract tells consumers that JUDGE
|
|
219
|
+
* (as opposed to consumers that walk operands positionally) to consider both;
|
|
220
|
+
* matching in either reading is the fail-CLOSED direction for a gate, since a
|
|
221
|
+
* match means "count it / require a template", never "let it through".
|
|
222
|
+
*
|
|
223
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
224
|
+
* @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
|
|
225
|
+
*/
|
|
226
|
+
function matchStatement(tokens) {
|
|
227
|
+
if (!Array.isArray(tokens) || tokens.length < 3) return null;
|
|
228
|
+
let resolved;
|
|
229
|
+
try {
|
|
230
|
+
resolved = resolveSegmentVerb(tokens);
|
|
231
|
+
} catch {
|
|
232
|
+
return null; // fail OPEN, same posture as statementsOf
|
|
233
|
+
}
|
|
234
|
+
for (const reading of [resolved, resolved.alt]) {
|
|
235
|
+
if (!reading || typeof reading.verb !== 'string' || reading.index < 0) continue;
|
|
236
|
+
const shape = matchReading(reading.verb, tokens, reading.index);
|
|
237
|
+
if (shape) return shape;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Find the first statement in the command chain that is a VCS-create call.
|
|
244
|
+
*
|
|
245
|
+
* Returns the shape AND the statement's tokens, because `extractTitle` must
|
|
246
|
+
* read `--title` off the SAME statement the gate fired on — reading it off the
|
|
247
|
+
* whole command silently returns a neighbouring statement's title (e.g. the
|
|
248
|
+
* `--title` of a preceding `glab issue list --search …`).
|
|
249
|
+
*
|
|
250
|
+
* @param {string} command
|
|
251
|
+
* @returns {{ shape: { host: string, kind: string, verb: string },
|
|
252
|
+
* tokens: Array<{ text: string, quoted: boolean }> } | null}
|
|
253
|
+
*/
|
|
254
|
+
function findCreateStatement(command) {
|
|
255
|
+
for (const tokens of statementsOf(command)) {
|
|
256
|
+
const shape = matchStatement(tokens);
|
|
257
|
+
if (shape) return { shape, tokens };
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse a shell command into its VCS-create shape.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} command
|
|
266
|
+
* @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
|
|
267
|
+
* `null` when no statement in the command is a `gh`/`glab` create/new call.
|
|
268
|
+
*/
|
|
269
|
+
export function matchVcsCreate(command) {
|
|
270
|
+
return findCreateStatement(command)?.shape ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
49
273
|
/**
|
|
50
274
|
* Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
|
|
51
275
|
* Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
|
|
@@ -70,10 +294,107 @@ export function isIssueCreate(command) {
|
|
|
70
294
|
}
|
|
71
295
|
|
|
72
296
|
/**
|
|
73
|
-
* True when the
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
297
|
+
* True when the matched issue-create statement sits inside a LOOP BODY, i.e.
|
|
298
|
+
* the command creates an unknown number of issues (#1145).
|
|
299
|
+
*
|
|
300
|
+
* ## Why this is a separate question from {@link isIssueCreate}
|
|
301
|
+
*
|
|
302
|
+
* `for t in a b c; do glab issue create --title $t; done` is textually ONE
|
|
303
|
+
* create statement that files THREE issues. Since #1145 the segmenter strips
|
|
304
|
+
* `do`, so the statement is finally visible to the cap — but charging it `1`
|
|
305
|
+
* would be a worse failure than the pre-#1145 miss: the ledger would then carry
|
|
306
|
+
* a number it KNOWS is wrong, and `for i in $(seq 1 50)` would file 50 issues
|
|
307
|
+
* against a count of 1. The true multiplicity is not computable at hook time
|
|
308
|
+
* (`$(cat list)`, `"$@"`, a glob) — so this function reports the FACT and the
|
|
309
|
+
* hook decides the POLICY (`hooks/pre-bash-issue-budget.mjs` G3b denies in
|
|
310
|
+
* `strict`, warns in `warn`).
|
|
311
|
+
*
|
|
312
|
+
* ## Mechanism — derived from the lexer, not re-derived here
|
|
313
|
+
*
|
|
314
|
+
* `do` and `done` open and close every bash loop body (`for`, `while`,
|
|
315
|
+
* `until`, `select` all use them) and both are in `command-blocker.mjs`'s
|
|
316
|
+
* `COMMAND_POSITION_KEYWORDS`. A token disappears between `tokenizeCommand`
|
|
317
|
+
* and `splitChainSegments` for exactly two reasons: it was a chain SEPARATOR,
|
|
318
|
+
* or it was a reserved word in COMMAND POSITION. `do`/`done` are not
|
|
319
|
+
* separators — so a `do` that is absent from the segments is provably a
|
|
320
|
+
* command-position `do`, and `echo do` / `--title done` (kept, because they
|
|
321
|
+
* are ordinary arguments) are provably not. Identity is object identity: the
|
|
322
|
+
* splitter pushes the very token objects the tokenizer produced.
|
|
323
|
+
*
|
|
324
|
+
* That is why this does not re-implement the separator scan: re-deriving
|
|
325
|
+
* command position here would be the second, silently-diverging shell parser
|
|
326
|
+
* this whole file exists to prevent.
|
|
327
|
+
*
|
|
328
|
+
* Depth is counted so a create AFTER a loop is not implicated:
|
|
329
|
+
* `while x; do echo a; done; glab issue create` → depth 0 at the create.
|
|
330
|
+
*
|
|
331
|
+
* NAMED CEILING (BV-004): this is coupled to `do`/`done` remaining in
|
|
332
|
+
* `COMMAND_POSITION_KEYWORDS`. If either is removed there, the token stops
|
|
333
|
+
* disappearing and this silently returns `false` — an under-report, i.e. back
|
|
334
|
+
* to charging 1 for N. The unit test `tests/hooks/vcs-create-matcher.test.mjs`
|
|
335
|
+
* pins the coupling directly. Revisit if that set is ever narrowed.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} command
|
|
338
|
+
* @returns {boolean} false when the command has no issue-create statement at all
|
|
339
|
+
*/
|
|
340
|
+
export function isLoopedIssueCreate(command) {
|
|
341
|
+
if (typeof command !== 'string' || command.length === 0) return false;
|
|
342
|
+
|
|
343
|
+
let tokens;
|
|
344
|
+
let segments;
|
|
345
|
+
try {
|
|
346
|
+
tokens = tokenizeCommand(command);
|
|
347
|
+
segments = splitChainSegments(tokens);
|
|
348
|
+
} catch {
|
|
349
|
+
return false; // fail OPEN, same posture as statementsOf
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The create statement's HEAD token, by object identity — the position in the
|
|
353
|
+
// raw stream at which the loop depth must be read.
|
|
354
|
+
//
|
|
355
|
+
// Deliberately the FIRST create statement of any kind, then a kind check —
|
|
356
|
+
// exactly what isIssueCreate() does via matchVcsCreate(). Searching for the
|
|
357
|
+
// first ISSUE create instead would diverge on `glab mr create … ; glab issue
|
|
358
|
+
// create …`: the hook's G3 would look at the mr, this at the issue, and the
|
|
359
|
+
// two would disagree about which statement they are judging.
|
|
360
|
+
let headTok = null;
|
|
361
|
+
for (const seg of segments) {
|
|
362
|
+
const shape = matchStatement(seg);
|
|
363
|
+
if (!shape) continue;
|
|
364
|
+
if (shape.kind === 'issue') headTok = seg[0];
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (!headTok) return false;
|
|
368
|
+
|
|
369
|
+
const kept = new Set(segments.flat());
|
|
370
|
+
let depth = 0;
|
|
371
|
+
for (const tok of tokens) {
|
|
372
|
+
if (tok === headTok) return depth > 0;
|
|
373
|
+
if (tok.quoted || kept.has(tok)) continue; // an argument, or a separator
|
|
374
|
+
if (tok.text === 'do') depth += 1;
|
|
375
|
+
else if (tok.text === 'done' && depth > 0) depth -= 1;
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* True when any STATEMENT in the command starts with one of the bypass
|
|
382
|
+
* patterns.
|
|
383
|
+
*
|
|
384
|
+
* Comparison is token-prefix, not string-prefix: the pattern is lexed with the
|
|
385
|
+
* same lexer as the command, and every one of its tokens must equal the
|
|
386
|
+
* statement's token at the same index. That preserves the property the previous
|
|
387
|
+
* string-prefix + boundary check existed for — a policy entry
|
|
388
|
+
* `gh issue create --label bot` must not match `--label botanical`, because
|
|
389
|
+
* `bot !== 'botanical'` as tokens — while dropping the hand-rolled boundary
|
|
390
|
+
* scan and, incidentally, tolerating whitespace and quoting differences between
|
|
391
|
+
* policy text and command text.
|
|
392
|
+
*
|
|
393
|
+
* Statement-scoped for the same reason the matcher is (#1106): after splitting,
|
|
394
|
+
* `cd /repo && gh pr create --dry-run` is a gated create call, and the
|
|
395
|
+
* operator's `gh pr create --dry-run` bypass entry must still exempt it.
|
|
396
|
+
* Checking the bypass against the whole command string would leave that entry
|
|
397
|
+
* dead exactly for the shapes the widening newly gates.
|
|
77
398
|
*
|
|
78
399
|
* @param {string} command
|
|
79
400
|
* @param {string[]} bypassPatterns
|
|
@@ -84,14 +405,37 @@ export function matchesBypass(command, bypassPatterns) {
|
|
|
84
405
|
if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
|
|
85
406
|
return false;
|
|
86
407
|
}
|
|
87
|
-
|
|
408
|
+
|
|
409
|
+
// The bypass is checked against THE CREATE STATEMENT, never against any
|
|
410
|
+
// statement in the chain. Getting this wrong is a security regression, and it
|
|
411
|
+
// was one: scoping the MATCH to statements while leaving the BYPASS a boolean
|
|
412
|
+
// over the whole command let an appended decoy lift the gate for a real call.
|
|
413
|
+
// Reproduced 2026-08-23 against the live policy (`glab issue create --label ci`
|
|
414
|
+
// is a real bypass_patterns entry):
|
|
415
|
+
// "glab issue create --title A" -> DENY
|
|
416
|
+
// "glab issue create --title REAL; glab issue create --label ci" -> ALLOW
|
|
417
|
+
// The second one creates an untemplated issue titled REAL. The pre-#1106 code
|
|
418
|
+
// was not vulnerable here — it prefix-matched the whole command, so a TRAILING
|
|
419
|
+
// pattern could not match. Widening the matcher without narrowing this opened it.
|
|
420
|
+
//
|
|
421
|
+
// The documented intent still holds: `cd /repo && gh pr create --dry-run` must
|
|
422
|
+
// remain exempt when `gh pr create --dry-run` is a bypass entry — that is the
|
|
423
|
+
// create statement itself, so it matches.
|
|
424
|
+
const create = findCreateStatement(command);
|
|
425
|
+
if (!create) return false;
|
|
426
|
+
const tokens = create.tokens;
|
|
427
|
+
|
|
88
428
|
for (const pat of bypassPatterns) {
|
|
89
429
|
if (typeof pat !== 'string' || pat.length === 0) continue;
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
430
|
+
const patStatements = statementsOf(pat);
|
|
431
|
+
// A pattern that lexes to nothing (whitespace-only, comment-only) would
|
|
432
|
+
// prefix-match EVERY statement — treat it as no pattern at all.
|
|
433
|
+
if (patStatements.length === 0) continue;
|
|
434
|
+
const patTokens = patStatements[0].map((t) => t.text);
|
|
435
|
+
if (patTokens.length === 0) continue;
|
|
436
|
+
|
|
437
|
+
if (tokens.length < patTokens.length) continue;
|
|
438
|
+
if (patTokens.every((text, i) => tokens[i].text === text)) return true;
|
|
95
439
|
}
|
|
96
440
|
return false;
|
|
97
441
|
}
|
|
@@ -99,21 +443,36 @@ export function matchesBypass(command, bypassPatterns) {
|
|
|
99
443
|
/**
|
|
100
444
|
* Best-effort extraction of the `--title` value from a create command, for
|
|
101
445
|
* human-readable overflow bookkeeping. Handles `--title "x"`, `--title 'x'`,
|
|
102
|
-
* `--title=x` and the bare unquoted form
|
|
103
|
-
*
|
|
446
|
+
* `--title=x` and the bare unquoted form — uniformly, because the lexer has
|
|
447
|
+
* already resolved quoting by the time this reads the tokens.
|
|
104
448
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
449
|
+
* Scoped to the create STATEMENT when there is one (#1106): reading `--title`
|
|
450
|
+
* off the whole command picks up a neighbouring statement's flag and labels the
|
|
451
|
+
* overflow record with the wrong title. Falls back to the whole token stream
|
|
452
|
+
* when no statement is a create call, preserving the previous lenient behaviour
|
|
453
|
+
* for callers that ask about a non-create command.
|
|
454
|
+
*
|
|
455
|
+
* Deliberately NOT a shell parser beyond that: the value is only ever used as a
|
|
456
|
+
* display label in an overflow record, never re-executed.
|
|
107
457
|
*
|
|
108
458
|
* @param {string} command
|
|
109
459
|
* @returns {string|null}
|
|
110
460
|
*/
|
|
111
461
|
export function extractTitle(command) {
|
|
112
462
|
if (typeof command !== 'string' || command.length === 0) return null;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
463
|
+
|
|
464
|
+
const found = findCreateStatement(command);
|
|
465
|
+
const tokens = found ? found.tokens : statementsOf(command).flat();
|
|
466
|
+
|
|
467
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
468
|
+
const text = tokens[i].text;
|
|
469
|
+
if (text === '--title') {
|
|
470
|
+
const value = tokens[i + 1]?.text;
|
|
471
|
+
return typeof value === 'string' ? value.trim() || null : null;
|
|
472
|
+
}
|
|
473
|
+
if (text.startsWith('--title=')) {
|
|
474
|
+
return text.slice('--title='.length).trim() || null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
119
478
|
}
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
* Exit codes: 0 always (informational, never blocking).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
28
27
|
import path from 'node:path';
|
|
29
28
|
|
|
30
29
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
@@ -32,6 +31,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
32
31
|
if (!shouldRunHook('cwd-change-restore')) process.exit(0);
|
|
33
32
|
|
|
34
33
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
34
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
35
35
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Constants
|
|
@@ -71,32 +71,6 @@ function readStdinJson() {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* Atomic read-modify-write of a JSON file via temp + rename.
|
|
76
|
-
* Reads the existing file (or starts with `defaultValue` when absent),
|
|
77
|
-
* applies `mutate`, writes to a tmp file, then renames over the original.
|
|
78
|
-
* Atomic on POSIX (same-filesystem rename). Best-effort on Windows.
|
|
79
|
-
*
|
|
80
|
-
* @param {string} filePath
|
|
81
|
-
* @param {object} defaultValue — used when the file does not exist or is unparseable
|
|
82
|
-
* @param {function(object): object} mutate — synchronous pure transformer
|
|
83
|
-
*/
|
|
84
|
-
async function atomicMutateJson(filePath, defaultValue, mutate) {
|
|
85
|
-
let current = defaultValue;
|
|
86
|
-
try {
|
|
87
|
-
const raw = await readFile(filePath, 'utf8');
|
|
88
|
-
current = JSON.parse(raw);
|
|
89
|
-
} catch {
|
|
90
|
-
// File absent or unparseable — start from defaultValue.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const updated = mutate(current);
|
|
94
|
-
const tmp = `${filePath}.tmp-cwd-${process.pid}-${Date.now()}`;
|
|
95
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
96
|
-
await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
97
|
-
await rename(tmp, filePath);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
74
|
// ---------------------------------------------------------------------------
|
|
101
75
|
// Main
|
|
102
76
|
// ---------------------------------------------------------------------------
|
|
@@ -117,14 +91,20 @@ async function main() {
|
|
|
117
91
|
|
|
118
92
|
const sessionFile = path.join(SO_PROJECT_DIR, '.orchestrator', 'current-session.json');
|
|
119
93
|
|
|
120
|
-
await atomicMutateJson(sessionFile, {}, (current) => {
|
|
94
|
+
const result = await atomicMutateJson(sessionFile, {}, (current) => {
|
|
121
95
|
const existing = Array.isArray(current.cwd_changes)
|
|
122
96
|
? current.cwd_changes
|
|
123
97
|
: [];
|
|
124
98
|
// Append the new record and cap at MAX_ENTRIES (keep most-recent).
|
|
125
99
|
const updated = [...existing, record].slice(-MAX_ENTRIES);
|
|
126
100
|
return { ...current, cwd_changes: updated };
|
|
127
|
-
});
|
|
101
|
+
}, 'cwd');
|
|
102
|
+
// Nothing else depends on this write — a non-ENOENT read/parse failure
|
|
103
|
+
// just means the record is dropped this turn. Diagnostic only (stderr),
|
|
104
|
+
// matches the hook's own "never blocking" contract.
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
console.error(`cwd-change-restore: atomicMutateJson skipped write (${result.reason})`);
|
|
107
|
+
}
|
|
128
108
|
}
|
|
129
109
|
|
|
130
110
|
// Exit 0 always — informational hook must never block Claude.
|