session-orchestrator 3.19.0 → 3.21.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 +10 -8
- package/CHANGELOG.md +494 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +757 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
|
@@ -35,17 +35,159 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
35
35
|
if (!shouldRunHook('enforce-commands')) process.exit(0);
|
|
36
36
|
|
|
37
37
|
import path from 'node:path';
|
|
38
|
+
import { pathToFileURL } from 'node:url';
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// #993 — late-bound repo dependencies
|
|
42
|
+
//
|
|
43
|
+
// These used to be STATIC imports. A SyntaxError in any of them failed at ESM
|
|
44
|
+
// LINK time, before the first statement here ran: node exited 1 with 0 bytes on
|
|
45
|
+
// stdout, and the `main().catch(...)` handler at the bottom of this file was
|
|
46
|
+
// structurally unreachable (it only covers runtime errors inside `main()`).
|
|
47
|
+
// Under the exit-0 PreToolUse protocol (#906) that crash is, on the only
|
|
48
|
+
// decision-bearing channel, INDISTINGUISHABLE from an explicit `emitAllow()` —
|
|
49
|
+
// the guard failed open and SILENTLY. This is the sibling defect #992 fixed for
|
|
50
|
+
// pre-bash-destructive-guard; #993 generalises the same repair here.
|
|
51
|
+
//
|
|
52
|
+
// Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
|
|
53
|
+
// link-time crash into a catchable runtime error, which is what makes the
|
|
54
|
+
// GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
|
|
55
|
+
//
|
|
56
|
+
// `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
|
|
57
|
+
// and gates whether this hook runs at all.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
60
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
61
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
62
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
63
|
+
let resolveProjectDir;
|
|
64
|
+
let readJson;
|
|
65
|
+
let findScopeFile;
|
|
66
|
+
let extractBashWriteTargets;
|
|
67
|
+
let pathMatchesPattern;
|
|
68
|
+
/**
|
|
69
|
+
* The `command-blocker.mjs` namespace, imported DIRECTLY (not via the
|
|
70
|
+
* hardening.mjs barrel — which does not carry the `headFallback` recovery, and
|
|
71
|
+
* which transitively imports command-blocker via scope-gate.mjs, so it fails
|
|
72
|
+
* anyway when command-blocker breaks). Mirrors the direct binding in
|
|
73
|
+
* pre-bash-destructive-guard / sessions-ledger-guard. Held as ONE object so the
|
|
74
|
+
* required-export list lives in exactly one place: the `requires` array on the
|
|
75
|
+
* `blocker` spec passed to `armGuard`.
|
|
76
|
+
*
|
|
77
|
+
* @type {Record<string, Function>|null}
|
|
78
|
+
*/
|
|
79
|
+
let blocker = null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Module labels `armGuard` recovered from HEAD because the working-tree copy
|
|
83
|
+
* failed (parse error OR shape check). Non-empty ⇒ this hook is armed against
|
|
84
|
+
* COMMITTED source. Surfaced on the visible stdout channel by {@link flushNotices}
|
|
85
|
+
* (#1001) — the stderr DEGRADED banner alone is discarded under the exit-0
|
|
86
|
+
* protocol, which made a degraded ALLOW indistinguishable from a healthy one.
|
|
87
|
+
*
|
|
88
|
+
* @type {string[]}
|
|
89
|
+
*/
|
|
90
|
+
let degradedLabels = [];
|
|
91
|
+
|
|
92
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
93
|
+
|
|
94
|
+
/** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
|
|
95
|
+
const HOOK_NAME = 'enforce-commands';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The consequence block spliced VERBATIM into the DEGRADED and GUARD INACTIVE
|
|
99
|
+
* banners (#993), naming the enforcement this hook's outage stops applying.
|
|
100
|
+
*/
|
|
101
|
+
const GUARD_CONSEQUENCE = {
|
|
102
|
+
degraded: [
|
|
103
|
+
' Consequence: command enforcement IS still armed, but it is evaluating the',
|
|
104
|
+
' COMMITTED (HEAD) command-blocker — any uncommitted change to it is NOT in effect.',
|
|
105
|
+
],
|
|
106
|
+
inactive: [
|
|
107
|
+
' Consequence: blocked Bash commands (rm -rf, git push --force, git reset',
|
|
108
|
+
' --hard, and the wave blockedCommands list) are NOT being screened. This is',
|
|
109
|
+
' a BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
115
|
+
* is one of the ones that may have failed to load.
|
|
116
|
+
*
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function bannerProjectDir() {
|
|
120
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
|
|
125
|
+
* banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
|
|
126
|
+
* the entry-point catch banners.
|
|
127
|
+
*
|
|
128
|
+
* `hardening.mjs` is bound (no headFallback — it carries relative imports) for
|
|
129
|
+
* `findScopeFile` / `extractBashWriteTargets` / `pathMatchesPattern`; the two
|
|
130
|
+
* command-matching primitives come from the direct `blocker` namespace, which is
|
|
131
|
+
* the only entry that opts into the `git show HEAD:` recovery. Because hardening
|
|
132
|
+
* transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
|
|
133
|
+
* fails hardening FIRST (it arms before the headFallback entry) — so that case
|
|
134
|
+
* degrades straight to GUARD INACTIVE, git or no git.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Promise<void>}
|
|
137
|
+
*/
|
|
138
|
+
async function bootstrap() {
|
|
139
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
140
|
+
|
|
141
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
142
|
+
const { modules, degraded } = await armGuard(
|
|
143
|
+
{
|
|
144
|
+
io: { specifier: lib('io.mjs') },
|
|
145
|
+
platform: { specifier: lib('platform.mjs') },
|
|
146
|
+
common: { specifier: lib('common.mjs') },
|
|
147
|
+
hardening: { specifier: lib('hardening.mjs') },
|
|
148
|
+
blocker: {
|
|
149
|
+
specifier: lib('command-blocker.mjs'),
|
|
150
|
+
headFallback: true,
|
|
151
|
+
requires: ['commandMatchesBlocked', 'suggestForCommandBlock'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
hookName: HOOK_NAME,
|
|
156
|
+
repoRoot: PLUGIN_ROOT,
|
|
157
|
+
projectDir: bannerProjectDir(),
|
|
158
|
+
consequence: GUARD_CONSEQUENCE,
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
163
|
+
({ resolveProjectDir } = modules.platform);
|
|
164
|
+
({ readJson } = modules.common);
|
|
165
|
+
({ findScopeFile, extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
|
|
166
|
+
blocker = modules.blocker;
|
|
167
|
+
degradedLabels = degraded;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Flush the aggregated allow-with-notice channel (#1001).
|
|
172
|
+
*
|
|
173
|
+
* Notices raised during gate evaluation accumulate in `notices` and are emitted
|
|
174
|
+
* ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
|
|
175
|
+
* else a plain `emitAllow`. Both exit 0 and never return, so this is always the
|
|
176
|
+
* LAST statement on an allow path.
|
|
177
|
+
*
|
|
178
|
+
* Why aggregate instead of `emitWarn`-ing inline at the degraded site: `emitWarn`
|
|
179
|
+
* is `@returns never` (scripts/lib/io.mjs), so an inline call before the G6
|
|
180
|
+
* blocked-pattern loop would exit BEFORE any pattern was matched — turning every
|
|
181
|
+
* would-be DENY into an ALLOW-with-notice for the whole degraded session. A deny,
|
|
182
|
+
* when it fires, exits via `emitDeny` and these notices are simply dropped: DENY
|
|
183
|
+
* wins, and armGuard's stderr banner remains for CI/debug.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} notices
|
|
186
|
+
* @returns {never}
|
|
187
|
+
*/
|
|
188
|
+
function flushNotices(notices) {
|
|
189
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
190
|
+
}
|
|
49
191
|
|
|
50
192
|
// Fallback safety list — applied when scope.blockedCommands is empty.
|
|
51
193
|
// Keep in sync with hooks/enforce-commands.sh; v3 additions (#138, SECURITY-REQ-07)
|
|
@@ -71,11 +213,29 @@ async function main() {
|
|
|
71
213
|
const command = input?.tool_input?.command;
|
|
72
214
|
if (typeof command !== 'string' || command.length === 0) return emitAllow();
|
|
73
215
|
|
|
216
|
+
// #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
|
|
217
|
+
// non-Bash tool call or an empty command is not this hook's business, and must
|
|
218
|
+
// stay a bare allow so the notice does not ride every unrelated tool call).
|
|
219
|
+
// Flushed ONCE at the end of whichever allow path is taken; see flushNotices
|
|
220
|
+
// for why an inline emitWarn here would disarm the G6 deny below.
|
|
221
|
+
const notices = [];
|
|
222
|
+
// A guard armed from HEAD (working-tree module unparseable or shape-invalid) is
|
|
223
|
+
// a visible-channel concern: the DEGRADED banner rides stderr only, which exit 0
|
|
224
|
+
// discards. armGuard already fired that banner once per session; surface it on
|
|
225
|
+
// stdout too so a degraded ALLOW is not silently indistinguishable from a
|
|
226
|
+
// healthy one.
|
|
227
|
+
if (degradedLabels.length > 0) {
|
|
228
|
+
notices.push(
|
|
229
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
230
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
74
234
|
const projectRoot = resolveProjectDir();
|
|
75
235
|
|
|
76
236
|
// G3 — no scope file → allow
|
|
77
237
|
const scopePath = findScopeFile(projectRoot);
|
|
78
|
-
if (!scopePath) return
|
|
238
|
+
if (!scopePath) return flushNotices(notices);
|
|
79
239
|
|
|
80
240
|
// SECURITY-REQ-08: read scope file exactly once; use the parsed object
|
|
81
241
|
// for all subsequent gate checks.
|
|
@@ -106,30 +266,35 @@ async function main() {
|
|
|
106
266
|
}
|
|
107
267
|
|
|
108
268
|
// G4 — gate disabled → allow
|
|
109
|
-
if (!gateOn) return
|
|
269
|
+
if (!gateOn) return flushNotices(notices);
|
|
110
270
|
// G5 — enforcement "off" → allow
|
|
111
|
-
if (enforcement === 'off') return
|
|
271
|
+
if (enforcement === 'off') return flushNotices(notices);
|
|
112
272
|
|
|
113
273
|
// G6 — determine which list to check
|
|
114
274
|
const useFallback = blockedCommands.length === 0;
|
|
115
275
|
const patternsToCheck = useFallback ? FALLBACK_BLOCKED : blockedCommands;
|
|
116
276
|
|
|
117
277
|
for (const pattern of patternsToCheck) {
|
|
118
|
-
if (commandMatchesBlocked(command, pattern)) {
|
|
278
|
+
if (blocker.commandMatchesBlocked(command, pattern)) {
|
|
119
279
|
const prefix = useFallback
|
|
120
280
|
? 'Blocked by fallback safety list'
|
|
121
281
|
: 'Blocked command';
|
|
122
282
|
const reason = `${prefix}: '${pattern}' found in command`;
|
|
123
|
-
const suggestion = suggestForCommandBlock(pattern);
|
|
283
|
+
const suggestion = blocker.suggestForCommandBlock(pattern);
|
|
124
284
|
if (enforcement === 'strict') {
|
|
125
285
|
return emitDeny(reason, suggestion);
|
|
126
286
|
}
|
|
127
|
-
|
|
287
|
+
// Aggregated, not inline: when the guard is DEGRADED the notice list is
|
|
288
|
+
// non-empty and both lines ride ONE envelope (a second emitWarn would be
|
|
289
|
+
// unreachable — emitWarn never returns). Undegraded, notices is empty and
|
|
290
|
+
// this stays the single-line warn it has always been.
|
|
291
|
+
notices.push(`${reason} — ${suggestion}`);
|
|
292
|
+
return flushNotices(notices);
|
|
128
293
|
}
|
|
129
294
|
}
|
|
130
295
|
|
|
131
296
|
// G7 — no match → allow
|
|
132
|
-
return
|
|
297
|
+
return flushNotices(notices);
|
|
133
298
|
}
|
|
134
299
|
|
|
135
300
|
/**
|
|
@@ -181,6 +346,40 @@ function targetInWaveScope(target, allowedPaths, projectRoot) {
|
|
|
181
346
|
);
|
|
182
347
|
}
|
|
183
348
|
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Entry point (#993)
|
|
351
|
+
//
|
|
352
|
+
// TWO distinct failure classes, two distinct handlers — do NOT merge them:
|
|
353
|
+
//
|
|
354
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
|
|
355
|
+
// exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
|
|
356
|
+
// only decision-bearing channel, indistinguishable from an allow. Now it
|
|
357
|
+
// exits 0 (still fail-OPEN — a broken module must not brick the session,
|
|
358
|
+
// and emitDeny itself may be the module that failed to load) but SAYS SO,
|
|
359
|
+
// loudly: GUARD INACTIVE.
|
|
360
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
|
|
361
|
+
// guard armed and then tripped over a specific command; that fails CLOSED
|
|
362
|
+
// via emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
try {
|
|
365
|
+
await bootstrap();
|
|
366
|
+
} catch (loadError) {
|
|
367
|
+
try {
|
|
368
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
369
|
+
// hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
370
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
371
|
+
} catch {
|
|
372
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
373
|
+
// repeated noise beats a silent disarm.
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
'🚨 enforce-commands: GUARD INACTIVE — module load failed ' +
|
|
376
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
377
|
+
'Blocked Bash commands are NOT being screened. See issue #993.\n'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
381
|
+
}
|
|
382
|
+
|
|
184
383
|
// SECURITY-REQ-01 (F-03): top-level try/catch — never let exit 1 leak.
|
|
185
384
|
main().catch((e) => {
|
|
186
385
|
emitDeny('Internal hook error — request blocked for safety', `${e?.message || e}`);
|
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
* G7 relative path matches an allowedPaths pattern
|
|
21
21
|
* G8 (all passed) → allow
|
|
22
22
|
*
|
|
23
|
+
* Empty-allowedPaths reasoning (#1057): the VERDICT for an empty allowlist is
|
|
24
|
+
* unchanged (deny-all, the #256 contract), but the deny REASON is now classified
|
|
25
|
+
* — Discovery's read-only contract, an unreadable manifest, a crashed session's
|
|
26
|
+
* leftover, an incomplete `--union`, or undecidable. See
|
|
27
|
+
* `scripts/lib/scope-gate.mjs` § Empty-`allowedPaths` classification.
|
|
28
|
+
*
|
|
23
29
|
* Exit codes: 0 = allow 2 = deny
|
|
24
30
|
*
|
|
25
31
|
* SECURITY notes (inline refs):
|
|
@@ -48,20 +54,122 @@
|
|
|
48
54
|
|
|
49
55
|
import path from 'node:path';
|
|
50
56
|
import { promises as fs } from 'node:fs';
|
|
57
|
+
import { pathToFileURL } from 'node:url';
|
|
51
58
|
|
|
52
59
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
53
60
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
54
61
|
if (!shouldRunHook('enforce-scope')) process.exit(0);
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// #993 — late-bound repo dependencies
|
|
65
|
+
//
|
|
66
|
+
// These used to be STATIC imports. A SyntaxError in any of them failed at ESM
|
|
67
|
+
// LINK time, before the first statement here ran: node exited 1 with 0 bytes on
|
|
68
|
+
// stdout, and the `main().catch(...)` handler at the bottom of this file was
|
|
69
|
+
// structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that
|
|
70
|
+
// crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an
|
|
71
|
+
// explicit `emitAllow()` — the guard failed open and SILENTLY. This is the
|
|
72
|
+
// sibling defect #992 fixed for pre-bash-destructive-guard; #993 generalises the
|
|
73
|
+
// same repair here.
|
|
74
|
+
//
|
|
75
|
+
// Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
|
|
76
|
+
// link-time crash into a catchable runtime error, which is what makes the
|
|
77
|
+
// GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
|
|
78
|
+
//
|
|
79
|
+
// BANNER-ONLY (#993 D1): this hook consumes ZERO command-blocker symbols, so no
|
|
80
|
+
// module here opts into the `git show HEAD:` fallback — every load failure
|
|
81
|
+
// degrades straight to GUARD INACTIVE, never DEGRADED.
|
|
82
|
+
//
|
|
83
|
+
// `profile-gate.mjs` and `node:*` builtins stay static — they cannot be the
|
|
84
|
+
// broken repo module.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
87
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
88
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
89
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
90
|
+
let isPathInside;
|
|
91
|
+
let relativeFromRoot;
|
|
92
|
+
let resolveProjectDir;
|
|
93
|
+
let findScopeFile;
|
|
94
|
+
let pathMatchesPattern;
|
|
95
|
+
let suggestForScopeViolation;
|
|
96
|
+
let readJson;
|
|
97
|
+
// #1057 — empty-`allowedPaths` classification + the session clock it needs.
|
|
98
|
+
let classifyEmptyScope;
|
|
99
|
+
let suggestForEmptyScope;
|
|
100
|
+
let sessionStartedAtMs;
|
|
101
|
+
|
|
102
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
103
|
+
|
|
104
|
+
/** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
|
|
105
|
+
const HOOK_NAME = 'enforce-scope';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
|
|
109
|
+
* naming the enforcement this hook's outage stops applying. No `degraded` block:
|
|
110
|
+
* this hook has no headFallback module, so it can never DEGRADE — only go INACTIVE.
|
|
111
|
+
*/
|
|
112
|
+
const GUARD_CONSEQUENCE = {
|
|
113
|
+
inactive: [
|
|
114
|
+
' Consequence: Edit/Write/MultiEdit scope enforcement is OFF — writes',
|
|
115
|
+
' outside the wave allowedPaths (and outside the project root) are NOT',
|
|
116
|
+
' being blocked. This is a BROKEN GUARD, not a policy decision — do not',
|
|
117
|
+
' route around it, repair it.',
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
123
|
+
* is one of the ones that may have failed to load.
|
|
124
|
+
*
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function bannerProjectDir() {
|
|
128
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
|
|
133
|
+
* banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
|
|
134
|
+
* the entry-point catch banners. No entry opts into headFallback — this hook is
|
|
135
|
+
* banner-only (#993 D1).
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async function bootstrap() {
|
|
140
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
141
|
+
|
|
142
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
143
|
+
const { modules } = await armGuard(
|
|
144
|
+
{
|
|
145
|
+
io: { specifier: lib('io.mjs') },
|
|
146
|
+
pathUtils: { specifier: lib('path-utils.mjs') },
|
|
147
|
+
platform: { specifier: lib('platform.mjs') },
|
|
148
|
+
hardening: { specifier: lib('hardening.mjs') },
|
|
149
|
+
common: { specifier: lib('common.mjs') },
|
|
150
|
+
// #1057. Bound DIRECTLY rather than through the `hardening.mjs` barrel:
|
|
151
|
+
// that barrel re-exports an explicit, frozen symbol list shared by six
|
|
152
|
+
// hooks, and widening it for one hook's need would drag three symbols
|
|
153
|
+
// into five unrelated import surfaces. `hardening` already imports
|
|
154
|
+
// `scope-gate` transitively, so this adds no new failure mode — a broken
|
|
155
|
+
// scope-gate already banners GUARD INACTIVE through that edge.
|
|
156
|
+
scopeGate: { specifier: lib('scope-gate.mjs') },
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
hookName: HOOK_NAME,
|
|
160
|
+
repoRoot: PLUGIN_ROOT,
|
|
161
|
+
projectDir: bannerProjectDir(),
|
|
162
|
+
consequence: GUARD_CONSEQUENCE,
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
167
|
+
({ isPathInside, relativeFromRoot } = modules.pathUtils);
|
|
168
|
+
({ resolveProjectDir } = modules.platform);
|
|
169
|
+
({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
|
|
170
|
+
({ readJson } = modules.common);
|
|
171
|
+
({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
|
|
172
|
+
}
|
|
65
173
|
|
|
66
174
|
async function main() {
|
|
67
175
|
// SECURITY-REQ-01: null-guard empty stdin — treat as allow (no input = not a real hook call)
|
|
@@ -93,15 +201,31 @@ async function main() {
|
|
|
93
201
|
if (!scopePath) return emitAllow();
|
|
94
202
|
|
|
95
203
|
// SECURITY-REQ-08: read scope file once; pass parsed object to all subsequent checks
|
|
204
|
+
//
|
|
205
|
+
// #1057: `parseOk` records WHETHER that read produced a usable scope RECORD.
|
|
206
|
+
// Both fold-to-`{}` / fold-to-`[]` paths (corrupt JSON #794 GAP-5, malformed
|
|
207
|
+
// shapes #558) already deny and CONTINUE to deny — the flag exists so the deny
|
|
208
|
+
// REASON can say "the manifest is broken" instead of "update the session plan
|
|
209
|
+
// and restart the wave", which is the one instruction that cannot help here.
|
|
96
210
|
let scope;
|
|
211
|
+
let parseOk = true;
|
|
97
212
|
try {
|
|
98
213
|
scope = await readJson(scopePath);
|
|
214
|
+
if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
|
|
215
|
+
parseOk = false;
|
|
216
|
+
scope = {};
|
|
217
|
+
}
|
|
99
218
|
} catch {
|
|
219
|
+
parseOk = false;
|
|
100
220
|
scope = {};
|
|
101
221
|
}
|
|
102
222
|
|
|
103
223
|
const enforcement = scope.enforcement ?? 'strict';
|
|
104
224
|
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
225
|
+
// A PRESENT-but-non-array `allowedPaths` (#558: null / string / object) is a
|
|
226
|
+
// MALFORMED record, not an empty grant — same class as unparseable JSON, so it
|
|
227
|
+
// earns the same reason. Absent is different and stays `parseOk`.
|
|
228
|
+
if (scope.allowedPaths !== undefined && !Array.isArray(scope.allowedPaths)) parseOk = false;
|
|
105
229
|
const gatesEnabled = scope.gates?.['path-guard'] !== false;
|
|
106
230
|
|
|
107
231
|
// Gate 4: path-guard gate explicitly disabled
|
|
@@ -110,6 +234,48 @@ async function main() {
|
|
|
110
234
|
// Gate 5: enforcement is turned off
|
|
111
235
|
if (enforcement === 'off') return emitAllow();
|
|
112
236
|
|
|
237
|
+
// -------------------------------------------------------------------------
|
|
238
|
+
// #1057 — WHY is the allowlist empty?
|
|
239
|
+
//
|
|
240
|
+
// FIVE repo states produce `allowedPaths.length === 0` and the DENY IS RIGHT
|
|
241
|
+
// IN ALL FIVE; only the instruction differs (Discovery's read-only contract,
|
|
242
|
+
// a corrupt manifest, a crashed session's leftover, an incomplete `--union`,
|
|
243
|
+
// or genuinely undecidable). This block is therefore VERDICT-NEUTRAL: it
|
|
244
|
+
// selects a sentence, never a decision — `suggest()` below is the only
|
|
245
|
+
// consumer, and its `'unknown'` branch is byte-identical to the pre-#1057 text.
|
|
246
|
+
//
|
|
247
|
+
// Computed AFTER the early-exit gates (a disabled or `off` wave pays no
|
|
248
|
+
// fs.stat) and BEFORE the first deny site, so all three deny sites share one
|
|
249
|
+
// explanation instead of drifting apart.
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
const emptyScopeReason =
|
|
252
|
+
allowedPaths.length === 0
|
|
253
|
+
? classifyEmptyScope({
|
|
254
|
+
role: scope.role,
|
|
255
|
+
parseOk,
|
|
256
|
+
scopeMtimeMs: await mtimeMsOf(scopePath),
|
|
257
|
+
sessionStartMs: sessionStartedAtMs(projectRoot),
|
|
258
|
+
})
|
|
259
|
+
: null;
|
|
260
|
+
const scopeRelRaw = relativeFromRoot(projectRoot, scopePath);
|
|
261
|
+
const scopeHint = (scopeRelRaw ?? scopePath).split(path.sep).join('/');
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The suggestion half of every deny below.
|
|
265
|
+
*
|
|
266
|
+
* With a NON-empty allowlist this is byte-identical to the pre-#1057 call.
|
|
267
|
+
* With an empty one it routes through the classifier — whose `'unknown'`
|
|
268
|
+
* branch delegates back to `suggestForScopeViolation(target, '')`, i.e. the
|
|
269
|
+
* same sentence as before. Strictly an addition.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} target
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
const suggest = (target) =>
|
|
275
|
+
emptyScopeReason === null
|
|
276
|
+
? suggestForScopeViolation(target, allowedPaths.join(', '))
|
|
277
|
+
: suggestForEmptyScope(target, emptyScopeReason, { role: scope.role, scopePath: scopeHint });
|
|
278
|
+
|
|
113
279
|
// SECURITY-REQ-06: resolve relative file_path against projectRoot, not process.cwd()
|
|
114
280
|
const absPathInput = path.isAbsolute(filePath)
|
|
115
281
|
? filePath
|
|
@@ -174,7 +340,7 @@ async function main() {
|
|
|
174
340
|
// Gate 6: path must be inside the project root
|
|
175
341
|
if (!isPathInside(resolvedPath, projectRoot)) {
|
|
176
342
|
const reason = `Scope violation: path outside project root`;
|
|
177
|
-
const suggestion =
|
|
343
|
+
const suggestion = suggest(filePath);
|
|
178
344
|
return enforcement === 'strict'
|
|
179
345
|
? emitDeny(reason, suggestion)
|
|
180
346
|
: emitWarn(`${reason} — ${suggestion}`);
|
|
@@ -186,7 +352,7 @@ async function main() {
|
|
|
186
352
|
// SECURITY-REQ-04: null return means outside root — deny rather than pass null to pathMatchesPattern
|
|
187
353
|
if (relPath === null) {
|
|
188
354
|
const reason = `Scope violation: '${filePath}' outside project root`;
|
|
189
|
-
const suggestion =
|
|
355
|
+
const suggestion = suggest(filePath);
|
|
190
356
|
return enforcement === 'strict'
|
|
191
357
|
? emitDeny(reason, suggestion)
|
|
192
358
|
: emitWarn(`${reason} — ${suggestion}`);
|
|
@@ -207,7 +373,7 @@ async function main() {
|
|
|
207
373
|
|
|
208
374
|
if (!matched) {
|
|
209
375
|
const reason = `Scope violation: '${normalizedRel}' not in allowed paths [${allowedPaths.join(', ')}]`;
|
|
210
|
-
const suggestion =
|
|
376
|
+
const suggestion = suggest(normalizedRel);
|
|
211
377
|
return enforcement === 'strict'
|
|
212
378
|
? emitDeny(reason, suggestion)
|
|
213
379
|
: emitWarn(`${reason} — ${suggestion}`);
|
|
@@ -217,6 +383,30 @@ async function main() {
|
|
|
217
383
|
return emitAllow();
|
|
218
384
|
}
|
|
219
385
|
|
|
386
|
+
/**
|
|
387
|
+
* `mtimeMs` of a file, or `null` when it cannot be stat'ed.
|
|
388
|
+
*
|
|
389
|
+
* The PROVENANCE half of the #1057 staleness comparison. It is fed into a
|
|
390
|
+
* SUBTRACTION against this session's start time — deliberately NOT compared to a
|
|
391
|
+
* TTL: an absolute age cap blinds the check in exactly the regime it exists for
|
|
392
|
+
* (a legitimate long deep session ages into the blind spot with nothing having
|
|
393
|
+
* gone wrong). Same argument `hooks/post-bash-write-verify.mjs` makes for
|
|
394
|
+
* `sessionAgeMs` under "Why the minimum, and why NOT a staleness cap".
|
|
395
|
+
*
|
|
396
|
+
* Never throws — an unstat-able manifest simply yields an undecidable clock, and
|
|
397
|
+
* {@link classifyEmptyScope} degrades to `'unknown'`, never to an allow.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} file
|
|
400
|
+
* @returns {Promise<number|null>}
|
|
401
|
+
*/
|
|
402
|
+
async function mtimeMsOf(file) {
|
|
403
|
+
try {
|
|
404
|
+
return (await fs.stat(file)).mtimeMs;
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
220
410
|
const COORDINATOR_CARVEOUT_PATHS = Object.freeze([
|
|
221
411
|
'.claude/STATE.md',
|
|
222
412
|
'.codex/STATE.md',
|
|
@@ -264,6 +454,40 @@ function matchesAbsoluteAllowlist(resolvedPath, allowedPaths) {
|
|
|
264
454
|
return abs.some((pat) => pathMatchesPattern(normalizedAbs, pat.split(path.sep).join('/')));
|
|
265
455
|
}
|
|
266
456
|
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Entry point (#993)
|
|
459
|
+
//
|
|
460
|
+
// TWO distinct failure classes, two distinct handlers — do NOT merge them:
|
|
461
|
+
//
|
|
462
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
|
|
463
|
+
// exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
|
|
464
|
+
// only decision-bearing channel, indistinguishable from an allow. Now it
|
|
465
|
+
// exits 0 (still fail-OPEN — a broken module must not brick the session,
|
|
466
|
+
// and emitDeny itself may be the module that failed to load) but SAYS SO:
|
|
467
|
+
// GUARD INACTIVE. Banner-only — no headFallback module here (#993 D1).
|
|
468
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
|
|
469
|
+
// guard armed and then tripped over a specific path; that fails CLOSED via
|
|
470
|
+
// emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
try {
|
|
473
|
+
await bootstrap();
|
|
474
|
+
} catch (loadError) {
|
|
475
|
+
try {
|
|
476
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
477
|
+
// hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
478
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
479
|
+
} catch {
|
|
480
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
481
|
+
// repeated noise beats a silent disarm.
|
|
482
|
+
process.stderr.write(
|
|
483
|
+
'🚨 enforce-scope: GUARD INACTIVE — module load failed ' +
|
|
484
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
485
|
+
'Edit/Write/MultiEdit scope enforcement is OFF. See issue #993.\n'
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
489
|
+
}
|
|
490
|
+
|
|
267
491
|
// SECURITY-REQ-01 (fail-closed): any unhandled rejection → structured deny, never bare exit 1
|
|
268
492
|
main().catch((e) => {
|
|
269
493
|
emitDeny(
|
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.21.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v3.21.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -95,6 +95,16 @@
|
|
|
95
95
|
"timeout": 5
|
|
96
96
|
}
|
|
97
97
|
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"matcher": "Agent",
|
|
101
|
+
"hooks": [
|
|
102
|
+
{
|
|
103
|
+
"type": "command",
|
|
104
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-task-scope-disjoint.mjs\"",
|
|
105
|
+
"timeout": 5
|
|
106
|
+
}
|
|
107
|
+
]
|
|
98
108
|
}
|
|
99
109
|
],
|
|
100
110
|
"PostToolUse": [
|