session-orchestrator 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guard-source-loader.mjs — degradation-VISIBLE module loading for deny-capable hooks.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect this closes (#992)
|
|
5
|
+
*
|
|
6
|
+
* `hooks/hooks.json` runs `sh run-node.sh <hook>.mjs`, which `exec node "$@"`.
|
|
7
|
+
* A deny-capable hook that STATICALLY imports a repo module inherits that
|
|
8
|
+
* module's parse failure at ESM LINK time — before the first statement of the
|
|
9
|
+
* hook body runs. Consequences, all measured:
|
|
10
|
+
*
|
|
11
|
+
* - the hook's own `main().catch(...)` handler is structurally unreachable
|
|
12
|
+
* (it only covers runtime errors inside `main()`),
|
|
13
|
+
* - node terminates with EXIT 1, **stdout 0 bytes**, stderr = a SyntaxError
|
|
14
|
+
* stack trace.
|
|
15
|
+
*
|
|
16
|
+
* Under the exit-0 PreToolUse protocol (#906) the exit code no longer carries a
|
|
17
|
+
* decision: ALLOW is `exit 0` + empty stdout, DENY is `exit 0` + exactly one
|
|
18
|
+
* `hookSpecificOutput` line. A crashed hook produces 0 bytes on stdout and is
|
|
19
|
+
* therefore, ON THE ONLY DECISION-BEARING CHANNEL, indistinguishable from an
|
|
20
|
+
* explicit `emitAllow()`. `git reset --hard` runs through. One broken
|
|
21
|
+
* `scripts/lib/command-blocker.mjs` silently disarms 4 of the 7 deny-capable
|
|
22
|
+
* hooks at once (destructive-guard, enforce-scope, enforce-commands,
|
|
23
|
+
* sessions-ledger-guard) — Bash *and* Edit/Write enforcement.
|
|
24
|
+
*
|
|
25
|
+
* ## The two parts, and why the banner is the base
|
|
26
|
+
*
|
|
27
|
+
* 1. **Loud once-per-session banner on EVERY load failure.** The damage was not
|
|
28
|
+
* "no fallback", it was an *invisible* outage: an ambiguous harness error
|
|
29
|
+
* line that wave agents read as a crash rather than a policy block, and
|
|
30
|
+
* began routing around. The banner therefore names the CONSEQUENCE ("guard
|
|
31
|
+
* INACTIVE" / "running against HEAD, not your working tree"), not just a
|
|
32
|
+
* file path.
|
|
33
|
+
* 2. **`git show HEAD:<path>` fallback — for `command-blocker.mjs` ONLY.** That
|
|
34
|
+
* module is dependency-free (its single import is `node:path`) and hence
|
|
35
|
+
* `data:`-URL loadable. Deliberately NOT generalised to the guard's other 6
|
|
36
|
+
* repo imports: those were never audited for RELATIVE imports, which a
|
|
37
|
+
* `data:` URL cannot resolve — that would need its own recursive resolver.
|
|
38
|
+
* For those modules part 1 (banner) stands alone.
|
|
39
|
+
*
|
|
40
|
+
* **The coupling is mandatory: the fallback must never fire silently.** A
|
|
41
|
+
* successful HEAD fallback banners too ("running against HEAD, not the working
|
|
42
|
+
* tree"), otherwise a visible hole is traded for an invisible semantic drift.
|
|
43
|
+
*
|
|
44
|
+
* **The HEAD fallback presupposes a git checkout.** It shells out to
|
|
45
|
+
* `git show HEAD:<path>` in the PLUGIN ROOT (not the project dir). For an
|
|
46
|
+
* npm-installed plugin — or any tarball/vendored copy without a `.git` — that
|
|
47
|
+
* command fails by construction, so half of this module is structurally
|
|
48
|
+
* unavailable there: EVERY load failure degrades straight to the GUARD INACTIVE
|
|
49
|
+
* banner below. That is the designed fail-loud direction, not a regression, but
|
|
50
|
+
* it means the banner is the ONLY protection npm consumers get.
|
|
51
|
+
*
|
|
52
|
+
* ## Why the marker is not a mute switch (post-#992 hardening)
|
|
53
|
+
*
|
|
54
|
+
* The once-per-session throttle used to key on the mere EXISTENCE of a
|
|
55
|
+
* predictably-named tmp file, and it gated BOTH banner classes. Measured: an
|
|
56
|
+
* agent could `touch` that path (not a destructive command), then append a
|
|
57
|
+
* syntax error to a `scripts/lib/` module (not on any redirect denylist) and the
|
|
58
|
+
* total-outage banner went silent while the decision channel said ALLOW —
|
|
59
|
+
* strictly worse than the pre-#992 crash, which at least printed a stack trace.
|
|
60
|
+
* Two changes close it:
|
|
61
|
+
*
|
|
62
|
+
* - **The `inactive` (total-outage) banner is NOT throttled at all.** Once per
|
|
63
|
+
* call, every call. Throttling is for the *degraded* case (guard armed,
|
|
64
|
+
* evaluating HEAD); a guard that is OFF must keep saying so. Noise there is
|
|
65
|
+
* strictly cheaper than a silent disarm.
|
|
66
|
+
* - **Where the marker remains (`head-fallback`), CONTENT decides, not
|
|
67
|
+
* existence**: a scoped payload (magic + kind + banner key + projectDir
|
|
68
|
+
* digest + boot epoch) that an empty `touch` cannot forge, opened
|
|
69
|
+
* `O_CREAT|O_EXCL|O_NOFOLLOW` for write and `O_NOFOLLOW` for read — so the
|
|
70
|
+
* marker path can no longer be aimed at an arbitrary file via a symlink,
|
|
71
|
+
* and a foreign or stale file makes the banner REPEAT rather than vanish.
|
|
72
|
+
*
|
|
73
|
+
* Honest residual: an attacker who runs as the same uid and reproduces the
|
|
74
|
+
* payload format can still suppress the `head-fallback` banner. That is why the
|
|
75
|
+
* class that actually means "unprotected" no longer depends on the marker.
|
|
76
|
+
*
|
|
77
|
+
* Measured cost: `git show` median 4.2 ms (n=21) against a hook allow-path
|
|
78
|
+
* median of 61 ms (n=15) — +11 ms, and only in the defect case. Zero in normal
|
|
79
|
+
* operation: nothing here runs unless an import already threw.
|
|
80
|
+
*
|
|
81
|
+
* ## Hard constraint on this file
|
|
82
|
+
*
|
|
83
|
+
* Everything below runs on the error path of a module-loading failure, so it
|
|
84
|
+
* MUST NOT import any repo module that could itself be the broken one —
|
|
85
|
+
* `node:*` builtins only. Keep it that way. An on-disk source cache under
|
|
86
|
+
* `.orchestrator/runtime/` was considered and REJECTED: its cold-start failure
|
|
87
|
+
* mode is exactly the target scenario (a fresh worktree mid-merge), and it
|
|
88
|
+
* creates a deletable trust anchor inside the writable repo.
|
|
89
|
+
*
|
|
90
|
+
* Known, accepted gap: a COMMITTED conflict marker breaks the HEAD copy too —
|
|
91
|
+
* then only the banner fires. That is why the banner is the base and the
|
|
92
|
+
* fallback the topping.
|
|
93
|
+
*
|
|
94
|
+
* Issue: #992.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
import fs from 'node:fs';
|
|
98
|
+
import os from 'node:os';
|
|
99
|
+
import path from 'node:path';
|
|
100
|
+
import crypto from 'node:crypto';
|
|
101
|
+
import { execFileSync } from 'node:child_process';
|
|
102
|
+
|
|
103
|
+
/** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
|
|
104
|
+
const BANNER_TTL_MS = 6 * 60 * 60 * 1000;
|
|
105
|
+
|
|
106
|
+
/** Marker payload discriminator — an empty `touch` matches none of it. */
|
|
107
|
+
const MARKER_MAGIC = 'session-orchestrator/guard-banner';
|
|
108
|
+
const MARKER_VERSION = 1;
|
|
109
|
+
|
|
110
|
+
/** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
|
|
111
|
+
const BOOT_BUCKET_S = 10;
|
|
112
|
+
|
|
113
|
+
/** Repo-relative path of the one module that gets the HEAD fallback. */
|
|
114
|
+
const COMMAND_BLOCKER_REL = 'scripts/lib/command-blocker.mjs';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The FULL export set `pre-bash-destructive-guard.mjs` needs from
|
|
118
|
+
* `command-blocker.mjs` — the single source of truth for the shape check.
|
|
119
|
+
*
|
|
120
|
+
* It lives here, and ONLY here, on purpose. The hook no longer destructures the
|
|
121
|
+
* module (it holds the namespace object and calls through it), so there is no
|
|
122
|
+
* second list to drift out of sync: a seventh export is added once, right here,
|
|
123
|
+
* and both the working-tree and the HEAD copy are validated against it.
|
|
124
|
+
*
|
|
125
|
+
* Why the check must cover all of them: it used to assert 2 of the 6, so a HEAD
|
|
126
|
+
* copy OLDER than the working tree — the normal case when a newly added export
|
|
127
|
+
* is the very thing that broke, i.e. the #982/#983/#988 history — passed as
|
|
128
|
+
* "DEGRADED, enforcement IS still armed" and then allowed every command with an
|
|
129
|
+
* `⚠ internal error — <fn> is not a function` line. A fallback that cannot
|
|
130
|
+
* enforce must banner as a TOTAL failure, never as "still armed".
|
|
131
|
+
*/
|
|
132
|
+
const COMMAND_BLOCKER_EXPORTS = [
|
|
133
|
+
'tokenizeCommand',
|
|
134
|
+
'commandMatchesBlocked',
|
|
135
|
+
'extractRedirectTargets',
|
|
136
|
+
'redirectRuleMatches',
|
|
137
|
+
'resolveSegmentVerb',
|
|
138
|
+
'splitChainSegments',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the once-per-session banner key.
|
|
143
|
+
*
|
|
144
|
+
* At ESM link-time failure stdin has NOT been read yet, so the payload's
|
|
145
|
+
* `session_id` is unavailable — and `readStdin` lives in `io.mjs`, which may
|
|
146
|
+
* itself be the broken module. So the id is read with `node:fs` alone from
|
|
147
|
+
* `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
|
|
148
|
+
* marker exactly like `run-node.sh` does.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} projectDir
|
|
151
|
+
* @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
|
|
152
|
+
* session-scoped — apply the 6h time TTL instead of pure existence".
|
|
153
|
+
*/
|
|
154
|
+
function resolveBannerKey(projectDir) {
|
|
155
|
+
try {
|
|
156
|
+
const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
|
|
157
|
+
const id = JSON.parse(raw)?.session_id;
|
|
158
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
159
|
+
return { key: id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64), ttl: false };
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* no lock, unreadable, or malformed — fall through to the time TTL */
|
|
163
|
+
}
|
|
164
|
+
return { key: 'ttl', ttl: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Per-project marker scope — the digest half of the marker file name. */
|
|
168
|
+
function markerScope(projectDir) {
|
|
169
|
+
return crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Coarse boot epoch (unix seconds, bucketed), used to invalidate markers left
|
|
174
|
+
* behind by a previous boot in a persistent `/tmp`. Bucketing absorbs the
|
|
175
|
+
* sub-second jitter between two `os.uptime()` reads; the reader additionally
|
|
176
|
+
* accepts ±1 bucket, so a call straddling a bucket edge is not a false miss.
|
|
177
|
+
*/
|
|
178
|
+
function bootEpochBucket() {
|
|
179
|
+
return Math.round((Date.now() / 1000 - os.uptime()) / BOOT_BUCKET_S);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Absolute path of the marker file that makes the banner once-per-session.
|
|
184
|
+
*
|
|
185
|
+
* Lives in the OS temp dir, NOT in the repo: the error path must not presuppose
|
|
186
|
+
* repo write access, and a marker inside the repo would be another deletable
|
|
187
|
+
* trust anchor. Built with `path.join(os.tmpdir(), …)` rather than string
|
|
188
|
+
* concatenation on `$TMPDIR` — that env var carries a trailing slash on macOS
|
|
189
|
+
* and is unset on a Linux container.
|
|
190
|
+
*
|
|
191
|
+
* The path is intentionally still derivable (it must be, across processes) —
|
|
192
|
+
* which is exactly why the path alone no longer decides anything: see
|
|
193
|
+
* `readMarker` for the payload the file has to carry.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} scope - `markerScope(projectDir)`, so parallel repos (and
|
|
196
|
+
* per-test fixture dirs) never share a marker.
|
|
197
|
+
* @param {string} kind - banner class (`head-fallback`).
|
|
198
|
+
* @param {string} key
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function bannerMarkerPath(scope, kind, key) {
|
|
202
|
+
return path.join(os.tmpdir(), `session-orchestrator-guard-${kind}-${scope}-${key}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read + VALIDATE a marker. Returns its write time, or `null` for "no marker of
|
|
207
|
+
* ours here" — which makes the banner fire.
|
|
208
|
+
*
|
|
209
|
+
* Every rejection path is deliberately the fail-LOUD one. A file that exists but
|
|
210
|
+
* does not carry this exact payload (an empty `touch`, a foreign file, a marker
|
|
211
|
+
* from another project, kind, session, or boot) is NOT a suppression signal.
|
|
212
|
+
*
|
|
213
|
+
* `O_NOFOLLOW` matters on both halves of the marker lifecycle: without it the
|
|
214
|
+
* predictable path is an arbitrary-file-write primitive (aim a symlink at any
|
|
215
|
+
* file the session can write, and the marker write truncates it) and an
|
|
216
|
+
* arbitrary-file-READ oracle.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} marker
|
|
219
|
+
* @param {{kind: string, key: string, scope: string}} expected
|
|
220
|
+
* @returns {{at: number}|null}
|
|
221
|
+
*/
|
|
222
|
+
function readMarker(marker, expected) {
|
|
223
|
+
let fd;
|
|
224
|
+
try {
|
|
225
|
+
fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
226
|
+
const st = fs.fstatSync(fd);
|
|
227
|
+
// Regular file, single link, owned by us. A hard link or a foreign-uid file
|
|
228
|
+
// means somebody else controls this path — never honour it.
|
|
229
|
+
if (!st.isFile() || st.nlink !== 1) return null;
|
|
230
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return null;
|
|
231
|
+
|
|
232
|
+
const raw = fs.readFileSync(fd, 'utf8');
|
|
233
|
+
const rec = JSON.parse(raw);
|
|
234
|
+
if (rec?.magic !== MARKER_MAGIC || rec?.v !== MARKER_VERSION) return null;
|
|
235
|
+
if (rec.kind !== expected.kind || rec.key !== expected.key || rec.scope !== expected.scope) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (Math.abs(Number(rec.boot) - bootEpochBucket()) > 1) return null;
|
|
239
|
+
|
|
240
|
+
const at = Date.parse(rec.at);
|
|
241
|
+
if (!Number.isFinite(at) || at > Date.now() + 60_000) return null; // no future stamps
|
|
242
|
+
return { at };
|
|
243
|
+
} catch {
|
|
244
|
+
return null; // absent, symlinked (ELOOP), unreadable, or malformed
|
|
245
|
+
} finally {
|
|
246
|
+
if (fd !== undefined) {
|
|
247
|
+
try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create the marker exclusively. Never overwrites: `O_EXCL` fails when anything
|
|
254
|
+
* already sits at the path, and a pre-planted file is therefore left alone —
|
|
255
|
+
* the banner then simply repeats on every call, which is the safe direction.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} marker
|
|
258
|
+
* @param {{kind: string, key: string, scope: string}} fields
|
|
259
|
+
*/
|
|
260
|
+
function writeMarker(marker, fields) {
|
|
261
|
+
let fd;
|
|
262
|
+
try {
|
|
263
|
+
fd = fs.openSync(
|
|
264
|
+
marker,
|
|
265
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0),
|
|
266
|
+
0o600
|
|
267
|
+
);
|
|
268
|
+
fs.writeSync(
|
|
269
|
+
fd,
|
|
270
|
+
`${JSON.stringify({
|
|
271
|
+
magic: MARKER_MAGIC,
|
|
272
|
+
v: MARKER_VERSION,
|
|
273
|
+
...fields,
|
|
274
|
+
boot: bootEpochBucket(),
|
|
275
|
+
at: new Date().toISOString(),
|
|
276
|
+
})}\n`
|
|
277
|
+
);
|
|
278
|
+
} catch {
|
|
279
|
+
/* unwritable tmp / already present: emit anyway, repeatedly if need be */
|
|
280
|
+
} finally {
|
|
281
|
+
if (fd !== undefined) {
|
|
282
|
+
try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Write stderr unconditionally. stdout is NEVER an option here: stdout is the
|
|
289
|
+
* decision channel and an allow REQUIRES an empty stdout (see
|
|
290
|
+
* `tests/_helpers/hook-decision.mjs`) — a banner there would corrupt every
|
|
291
|
+
* decision this hook makes.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} message
|
|
294
|
+
*/
|
|
295
|
+
function writeBanner(message) {
|
|
296
|
+
process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Write a guard-DEGRADATION banner to stderr, at most once per session.
|
|
301
|
+
*
|
|
302
|
+
* Throttling is confined to the degraded class on purpose: there the guard is
|
|
303
|
+
* still armed, so a per-call banner is pure noise an operator learns to ignore.
|
|
304
|
+
* The total-outage banner does NOT come through here — see
|
|
305
|
+
* `emitGuardInactiveBanner`.
|
|
306
|
+
*
|
|
307
|
+
* @param {{projectDir: string, kind: string, message: string}} opts
|
|
308
|
+
* @returns {boolean} whether the banner was emitted this call.
|
|
309
|
+
*/
|
|
310
|
+
function emitGuardBannerOnce({ projectDir, kind, message }) {
|
|
311
|
+
const { key, ttl } = resolveBannerKey(projectDir);
|
|
312
|
+
const scope = markerScope(projectDir);
|
|
313
|
+
const marker = bannerMarkerPath(scope, kind, key);
|
|
314
|
+
const fields = { kind, key, scope };
|
|
315
|
+
|
|
316
|
+
const existing = readMarker(marker, fields);
|
|
317
|
+
if (existing) {
|
|
318
|
+
if (!ttl) return false; // session-keyed: already bannered this session
|
|
319
|
+
if (Date.now() - existing.at < BANNER_TTL_MS) return false;
|
|
320
|
+
// Session-id-less TTL expiry: our own marker, verified above — refresh it.
|
|
321
|
+
try { fs.unlinkSync(marker); } catch { /* keep going; the write may still fail */ }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
writeMarker(marker, fields);
|
|
325
|
+
writeBanner(message);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Read a repo file's committed content via `git show HEAD:<relPath>`.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} repoRoot
|
|
333
|
+
* @param {string} relPath - POSIX, repo-relative.
|
|
334
|
+
* @returns {string} file content at HEAD.
|
|
335
|
+
* @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
|
|
336
|
+
*/
|
|
337
|
+
function readFromHead(repoRoot, relPath) {
|
|
338
|
+
return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
|
|
339
|
+
encoding: 'utf8',
|
|
340
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
341
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Import an ESM module from an in-memory source string.
|
|
347
|
+
*
|
|
348
|
+
* Only sound for a DEPENDENCY-FREE module (or one importing `node:*` only): a
|
|
349
|
+
* `data:` URL has no base for relative specifier resolution. `command-blocker.mjs`
|
|
350
|
+
* qualifies — its single import is `node:path`.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} source
|
|
353
|
+
* @returns {Promise<object>} the module namespace.
|
|
354
|
+
*/
|
|
355
|
+
function importFromSource(source) {
|
|
356
|
+
const b64 = Buffer.from(source, 'utf8').toString('base64');
|
|
357
|
+
return import(`data:text/javascript;base64,${b64}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Assert a loaded `command-blocker.mjs` namespace carries the COMPLETE API the
|
|
362
|
+
* guard calls through. A partial namespace is not a degraded guard — it is a
|
|
363
|
+
* guard that throws on the first command and fails open with an `internal
|
|
364
|
+
* error` line, so it must be treated as a load failure here, at the one place
|
|
365
|
+
* that can still fall back or banner.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} mod
|
|
368
|
+
* @param {string} origin - human label for the banner ("working-tree copy" | "HEAD copy")
|
|
369
|
+
* @throws {Error} naming every missing export.
|
|
370
|
+
*/
|
|
371
|
+
function assertBlockerShape(mod, origin) {
|
|
372
|
+
const missing = COMMAND_BLOCKER_EXPORTS.filter((name) => typeof mod?.[name] !== 'function');
|
|
373
|
+
if (missing.length > 0) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`${origin} of ${COMMAND_BLOCKER_REL} is missing required export(s): ${missing.join(', ')}`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Load `command-blocker.mjs`, falling back to its HEAD version, never silently.
|
|
382
|
+
*
|
|
383
|
+
* @param {{specifier: string, repoRoot: string, projectDir: string}} opts
|
|
384
|
+
* `specifier` is the import URL/path used in normal operation; `repoRoot` is
|
|
385
|
+
* where `git show` runs; `projectDir` keys the banner marker.
|
|
386
|
+
* @returns {Promise<{module: object, degraded: 'head'|null}>}
|
|
387
|
+
* @throws the ORIGINAL working-tree error when the HEAD fallback also fails, so
|
|
388
|
+
* the caller's catch can banner "guard INACTIVE" with the real cause.
|
|
389
|
+
*/
|
|
390
|
+
export async function loadCommandBlocker({ specifier, repoRoot, projectDir }) {
|
|
391
|
+
try {
|
|
392
|
+
const module = await import(specifier);
|
|
393
|
+
// The shape check runs on BOTH paths. A working-tree copy that parses but
|
|
394
|
+
// lost an export is the same defect class as a stale HEAD copy: without
|
|
395
|
+
// this it would arm "successfully" and then fail open per command.
|
|
396
|
+
assertBlockerShape(module, 'working-tree copy');
|
|
397
|
+
return { module, degraded: null };
|
|
398
|
+
} catch (workingTreeError) {
|
|
399
|
+
let mod;
|
|
400
|
+
try {
|
|
401
|
+
mod = await importFromSource(readFromHead(repoRoot, COMMAND_BLOCKER_REL));
|
|
402
|
+
// A HEAD copy that parses but lost part of the API is NOT a usable
|
|
403
|
+
// fallback — treat it as a total failure rather than half-arming.
|
|
404
|
+
assertBlockerShape(mod, 'HEAD copy');
|
|
405
|
+
} catch (headError) {
|
|
406
|
+
workingTreeError.headFallbackError = headError;
|
|
407
|
+
throw workingTreeError;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
emitGuardBannerOnce({
|
|
411
|
+
projectDir,
|
|
412
|
+
kind: 'head-fallback',
|
|
413
|
+
message: [
|
|
414
|
+
'',
|
|
415
|
+
'⚠️ pre-bash-destructive-guard: DEGRADED — running against HEAD, not your working tree.',
|
|
416
|
+
` ${COMMAND_BLOCKER_REL} failed to load from the working tree:`,
|
|
417
|
+
` ${String(workingTreeError?.message || workingTreeError).split('\n')[0]}`,
|
|
418
|
+
' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
|
|
419
|
+
' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
|
|
420
|
+
` Fix: repair ${COMMAND_BLOCKER_REL} (conflict markers? partial edit?) and re-run.`,
|
|
421
|
+
' See: issue #992.',
|
|
422
|
+
'',
|
|
423
|
+
].join('\n'),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return { module: mod, degraded: 'head' };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Banner for the total-failure case: no usable module, guard NOT armed.
|
|
432
|
+
*
|
|
433
|
+
* **Deliberately NOT throttled** — this is the one message that must survive
|
|
434
|
+
* every attempt to quiet it. Its predecessor went through the once-per-session
|
|
435
|
+
* marker, and because that marker was suppressible by a bare `touch` on a
|
|
436
|
+
* derivable path, the loudest signal in the system had the weakest lock on it:
|
|
437
|
+
* plant the file, break a module, and an unarmed guard said nothing at all
|
|
438
|
+
* while the decision channel said ALLOW. There is nothing to key on here
|
|
439
|
+
* anyway — every call after the first is equally unprotected, so every call
|
|
440
|
+
* has equal right to say so. Repetition is the point.
|
|
441
|
+
*
|
|
442
|
+
* `projectDir` is retained for signature symmetry with the degraded banner and
|
|
443
|
+
* for future scoping; it deliberately does not gate anything.
|
|
444
|
+
*
|
|
445
|
+
* @param {{projectDir?: string, error: unknown}} opts
|
|
446
|
+
*/
|
|
447
|
+
export function emitGuardInactiveBanner({ error }) {
|
|
448
|
+
const primary = String(error?.message || error).split('\n')[0];
|
|
449
|
+
const secondary = error?.headFallbackError
|
|
450
|
+
? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
|
|
451
|
+
: null;
|
|
452
|
+
|
|
453
|
+
writeBanner(
|
|
454
|
+
[
|
|
455
|
+
'',
|
|
456
|
+
'🚨 pre-bash-destructive-guard: GUARD INACTIVE — this session is NOT protected.',
|
|
457
|
+
` Module load failed: ${primary}`,
|
|
458
|
+
...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
|
|
459
|
+
' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
|
|
460
|
+
' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
|
|
461
|
+
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
462
|
+
' Fix: repair the failing module under scripts/lib/, then re-run.',
|
|
463
|
+
' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
|
|
464
|
+
'',
|
|
465
|
+
].join('\n')
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -187,6 +187,27 @@ export async function bootstrapLock({
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// Step 2b (#987 Part 1): persist the durable ownership proof at lock
|
|
191
|
+
// genesis. `enriched` is byte-identical to the on-disk lock at this point
|
|
192
|
+
// (the v2 overlay never touches pid/host/started_at), so the proof written
|
|
193
|
+
// here will verify via isLockOwnedByProof() against any later re-read.
|
|
194
|
+
// This single call covers BOTH the plain-acquire and the forceAcquire
|
|
195
|
+
// branch — both flow through the enriched write above. Best-effort like
|
|
196
|
+
// the surrounding breadcrumb writes: writeOwnerProof() is no-throw by
|
|
197
|
+
// contract and a failure never bails the bootstrap — but it is no longer
|
|
198
|
+
// SILENT (#987 Part 2 review finding): a failed proof write means this
|
|
199
|
+
// session's /close will degrade to the weaker proof-less release path, so
|
|
200
|
+
// a one-line stderr WARN gives the operator the only signal there is.
|
|
201
|
+
try {
|
|
202
|
+
const { writeOwnerProof } = await import('../../scripts/lib/session-lock.mjs');
|
|
203
|
+
const proofResult = writeOwnerProof({ repoRoot, lock: enriched });
|
|
204
|
+
if (proofResult && !proofResult.ok) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`⚠ lock-bootstrap: owner-proof write failed (${proofResult.reason ?? 'unknown'}) — /close degrades to proof-less release behaviour\n`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} catch { /* best-effort — a missing proof never breaks session-start */ }
|
|
210
|
+
|
|
190
211
|
// Step 3: best-effort observability breadcrumb. Failures are swallowed
|
|
191
212
|
// so a missing events module never breaks the hook.
|
|
192
213
|
try {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vcs-create-matcher.mjs — shared `gh` / `glab` create-command matcher for
|
|
3
|
+
* PreToolUse Bash hooks.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `hooks/pre-bash-templates-first.mjs` (#519) so the
|
|
6
|
+
* issue-budget hook (`hooks/pre-bash-issue-budget.mjs`) reuses the SAME
|
|
7
|
+
* regex + argument-boundary semantics instead of maintaining a second,
|
|
8
|
+
* silently-diverging copy. Both hooks share the PreToolUse Bash matcher and
|
|
9
|
+
* run sequentially, so a divergence here would mean one hook gates a command
|
|
10
|
+
* the other waves through.
|
|
11
|
+
*
|
|
12
|
+
* ZERO IMPORTS by design — a clean leaf so any hook can adopt it without
|
|
13
|
+
* pulling the scripts/lib layer in.
|
|
14
|
+
*
|
|
15
|
+
* LOAD-BEARING semantics (contract, mirrors the pre-#-extraction behaviour):
|
|
16
|
+
* MATCH: `gh|glab` + `pr|mr|issue` + `create|new`, anchored at start with
|
|
17
|
+
* optional leading whitespace, word-boundary on the trailing edge
|
|
18
|
+
* (so `created` / `news` do NOT match).
|
|
19
|
+
* NO MATCH: edit operations (`gh pr edit`, `glab mr edit`) — deliberately
|
|
20
|
+
* out of scope per the #519 PRD § 2 Out-of-Scope.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Matches the canonical `gh` / `glab` issue/PR/MR creation invocations.
|
|
25
|
+
* Anchored at start (^) with optional leading whitespace to catch indented
|
|
26
|
+
* shell snippets. Word-boundary at the end avoids false positives on tokens
|
|
27
|
+
* like `created` or `news`.
|
|
28
|
+
*/
|
|
29
|
+
export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a shell command into its VCS-create shape.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} command
|
|
35
|
+
* @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
|
+
*/
|
|
38
|
+
export function matchVcsCreate(command) {
|
|
39
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
40
|
+
const m = command.match(CREATE_REGEX);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
return {
|
|
43
|
+
host: m[1] === 'gh' ? 'github' : 'gitlab',
|
|
44
|
+
kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
|
|
45
|
+
verb: /** @type {'create'|'new'} */ (m[3]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
|
|
51
|
+
* Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} command
|
|
54
|
+
* @returns {"github"|"gitlab"|null}
|
|
55
|
+
*/
|
|
56
|
+
export function resolveHost(command) {
|
|
57
|
+
return matchVcsCreate(command)?.host ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when the command creates an ISSUE specifically (not a PR/MR).
|
|
62
|
+
* The issue-budget cap counts issues only — PR/MR creation is not the
|
|
63
|
+
* runaway-volume problem the cap exists to bound.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} command
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function isIssueCreate(command) {
|
|
69
|
+
return matchVcsCreate(command)?.kind === 'issue';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the command starts with any of the bypass patterns. Bypass match
|
|
74
|
+
* is a prefix check with a word/EOL boundary on the trailing edge — this
|
|
75
|
+
* prevents trivial bypass via prefix-inclusion (e.g. a policy entry
|
|
76
|
+
* "gh issue create --label bot" must not match "gh issue create --label botanical").
|
|
77
|
+
*
|
|
78
|
+
* @param {string} command
|
|
79
|
+
* @param {string[]} bypassPatterns
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function matchesBypass(command, bypassPatterns) {
|
|
83
|
+
if (typeof command !== 'string') return false;
|
|
84
|
+
if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const stripped = command.replace(/^\s+/, '');
|
|
88
|
+
for (const pat of bypassPatterns) {
|
|
89
|
+
if (typeof pat !== 'string' || pat.length === 0) continue;
|
|
90
|
+
const patStripped = pat.replace(/^\s+/, '');
|
|
91
|
+
if (!stripped.startsWith(patStripped)) continue;
|
|
92
|
+
// Boundary check: next character must be whitespace, EOL, or absent.
|
|
93
|
+
const nextChar = stripped.charAt(patStripped.length);
|
|
94
|
+
if (nextChar === '' || /\s/.test(nextChar)) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Best-effort extraction of the `--title` value from a create command, for
|
|
101
|
+
* human-readable overflow bookkeeping. Handles `--title "x"`, `--title 'x'`,
|
|
102
|
+
* `--title=x` and the bare unquoted form. Returns `null` when no title flag
|
|
103
|
+
* is present.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately NOT a shell parser: the value is only ever used as a display
|
|
106
|
+
* label in an overflow record, never re-executed.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} command
|
|
109
|
+
* @returns {string|null}
|
|
110
|
+
*/
|
|
111
|
+
export function extractTitle(command) {
|
|
112
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
113
|
+
const m =
|
|
114
|
+
command.match(/--title[=\s]+"((?:[^"\\]|\\.)*)"/) ??
|
|
115
|
+
command.match(/--title[=\s]+'([^']*)'/) ??
|
|
116
|
+
command.match(/--title[=\s]+(\S+)/);
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
return m[1].replace(/\\(["\\])/g, '$1').trim() || null;
|
|
119
|
+
}
|
|
Binary file
|
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
* G5 enforcement != "off"
|
|
14
14
|
* G6 blocked pattern match against .blockedCommands[], or
|
|
15
15
|
* fallback safety list when .blockedCommands is empty
|
|
16
|
-
* G7 strict → deny
|
|
16
|
+
* G7 strict → deny; warn → stderr + allow; otherwise allow
|
|
17
|
+
*
|
|
18
|
+
* DECISION CHANNEL (post-#906): a deny is signalled by the single nested
|
|
19
|
+
* PreToolUse JSON envelope emitDeny() writes to stdout, with exit **0** —
|
|
20
|
+
* NOT by exit 2. The docs forbid the mixed form ("Exit 2 … Claude Code
|
|
21
|
+
* ignores stdout and any JSON in it"), which silently discarded the reason
|
|
22
|
+
* and surfaced to the operator as a crash. Do not reintroduce `exit 2` here.
|
|
23
|
+
* Corollary: exit 0 alone no longer distinguishes allow from deny — the
|
|
24
|
+
* envelope's presence does, and a malformed envelope fails OPEN.
|
|
17
25
|
*
|
|
18
26
|
* SECURITY-REQ-01: try/catch on main(). emitDeny on any unhandled error —
|
|
19
|
-
*
|
|
27
|
+
* fail-closed, never a bare exit 1. Null-guard readStdin() return.
|
|
20
28
|
* SECURITY-REQ-07: FALLBACK_BLOCKED includes 'git push -f' and 'drop table'
|
|
21
29
|
* (short form + case variant gaps in the original Bash fallback list).
|
|
22
30
|
* SECURITY-REQ-08: scope file read exactly once per invocation.
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v3.19.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-cursor.json
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
"_comment": "Cursor IDE hook mapping reference. Configure hooks in Cursor Settings > Hooks.",
|
|
3
3
|
"_note": "Cursor hooks fire at different lifecycle points than Claude Code. See docs/cursor-setup.md for details.",
|
|
4
4
|
"_limitation": "SessionStart greeting (session-start skill) is NOT available on Cursor. Cursor has no conversation-start lifecycle event equivalent to Claude Code's UserPromptSubmit hook. Session initialisation must be triggered manually.",
|
|
5
|
+
"_enforcement": "reference-only",
|
|
6
|
+
"_enforcement_note": "MACHINE-READABLE STATUS (#919): every entry below is an intended mapping, NOT live enforcement. No Cursor payload adapter exists (Pi has scripts/lib/pi-hook-bridge.mjs; Cursor has nothing), so each handler short-circuits on a Cursor-shaped payload and writes 0 bytes with exit 0. Operator decision 2026-07-31: gap registered, not closed. The machine-readable counterpart of this key is DOCUMENTED_ASYMMETRIES.handlerAsymmetries.cursor in scripts/lib/validate/check-hooks-symmetry.mjs, which Check 6 counts per event; the projection that makes that comparison non-vacuous is DOCUMENTED_ASYMMETRIES.cursorEventMap.",
|
|
7
|
+
"_issues": ["#919", "#946"],
|
|
5
8
|
"hooks": {
|
|
6
9
|
"afterFileEdit": {
|
|
7
10
|
"script": "hooks/enforce-scope.mjs",
|
|
8
|
-
"
|
|
11
|
+
"enforcement": "none",
|
|
12
|
+
"issue": "#919",
|
|
13
|
+
"mapsToClaudeEvent": "PostToolUse",
|
|
14
|
+
"note": "Post-hoc scope warning — Cursor fires AFTER the edit (not before), so this maps onto PostToolUse, never PreToolUse. Cannot prevent out-of-scope edits, only warn — and today not even that: on a Cursor afterFileEdit payload enforce-scope.mjs writes 0 bytes to stdout AND stderr with exit 0 (measured 2026-07-31), because it reads Claude Code's PreToolUse field names."
|
|
9
15
|
},
|
|
10
16
|
"beforeShellExecution": {
|
|
11
17
|
"script": "hooks/enforce-commands.mjs",
|
|
12
|
-
"
|
|
18
|
+
"enforcement": "none",
|
|
19
|
+
"issue": "#919",
|
|
20
|
+
"mapsToClaudeEvent": "PreToolUse",
|
|
21
|
+
"note": "NOT WIRED (#919) — silent no-op on a Cursor payload, NOT a block. The handler reads Claude Code's PreToolUse field names (tool_name === 'Bash', tool_input.command) and emits a Claude Code PreToolUse envelope (hookSpecificOutput.permissionDecision). Fed a Cursor beforeShellExecution payload it short-circuits at gate G1 and writes 0 bytes to stdout AND stderr with exit 0 — the harness sees no decision, so the command runs. Cursor needs an input/output adapter like scripts/lib/pi-hook-bridge.mjs; none exists. Treat this entry as the intended mapping, not as live enforcement."
|
|
13
22
|
}
|
|
14
23
|
}
|
|
15
24
|
}
|