session-orchestrator 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -53,8 +53,9 @@
|
|
|
53
53
|
* a spaced subshell is a statement like any other. "Resolved verb"
|
|
54
54
|
* means: after leading `VAR=value` assignments, after transparent
|
|
55
55
|
* process wrappers (`nohup`, `command`, `sudo`, `env`, `timeout`,
|
|
56
|
-
* `
|
|
57
|
-
* (`/opt/homebrew/bin/glab`) resolves to `glab` (#1145).
|
|
56
|
+
* `exec`), and basename-normalised, so an absolute path
|
|
57
|
+
* (`/opt/homebrew/bin/glab`) resolves to `glab` (#1145). `xargs` is
|
|
58
|
+
* NOT one of those wrappers — see the named ceiling below.
|
|
58
59
|
* NO MATCH: the words inside a quoted string, a `#` comment, or a here-doc
|
|
59
60
|
* body (they are data, not a command); a single token that merely
|
|
60
61
|
* CONTAINS the three words (`"glab issue create"` runs a binary of
|
|
@@ -95,6 +96,28 @@
|
|
|
95
96
|
* triage of a per-session counter file
|
|
96
97
|
* (`.orchestrator/runtime/issue-budget/<hash>.json`, #1141) or in a
|
|
97
98
|
* transcript census of real create calls.
|
|
99
|
+
* - An `xargs`-driven create is NOT matched, and `xargs` is deliberately not
|
|
100
|
+
* a transparent wrapper: `command-blocker.mjs` classes it as an INTERPRETER
|
|
101
|
+
* (`SHELL_EXEC_INTERPRETERS`), because unwrapping it there would LOOSEN the
|
|
102
|
+
* destructive-command guard that shares this lexer. Measured 2026-09-09
|
|
103
|
+
* against this file — all four shapes yield 0 statements, so neither the
|
|
104
|
+
* cap nor the loop-deny sees them:
|
|
105
|
+
*
|
|
106
|
+
* xargs glab issue create --title X → 0
|
|
107
|
+
* echo X | xargs -I% glab issue create --title % → 0
|
|
108
|
+
* seq 1 50 | xargs -I% gh api -X POST …/issues -f title=% → 0
|
|
109
|
+
* xargs -n1 glab issue create → 0
|
|
110
|
+
*
|
|
111
|
+
* Widening this one shape means changing what `resolveSegmentVerb` reports
|
|
112
|
+
* for `xargs` — read by the four OTHER consumers of that resolver
|
|
113
|
+
* (`grep -rln resolveSegmentVerb scripts/ hooks/`, 2026-09-09:
|
|
114
|
+
* `scripts/lib/project-hygiene.mjs`, `scripts/lib/scope-gate.mjs`,
|
|
115
|
+
* `hooks/pre-bash-sessions-ledger-guard.mjs`,
|
|
116
|
+
* `hooks/pre-bash-destructive-guard.mjs`) — so it is a deliberate
|
|
117
|
+
* cross-consumer change, never a local patch here.
|
|
118
|
+
* Pinned by `tests/hooks/vcs-create-matcher.test.mjs`. Revisit when an
|
|
119
|
+
* `xargs`-driven create shows up in a transcript census or in the overflow
|
|
120
|
+
* triage of a per-session counter file.
|
|
98
121
|
* - A paren glued to the verb (`(glab issue create …)`) is not reached: it
|
|
99
122
|
* lexes as the single word `(glab`. This is `command-blocker.mjs`'s own
|
|
100
123
|
* named ceiling on `COMMAND_POSITION_KEYWORDS` (#1145), inherited here
|
|
@@ -205,9 +228,115 @@ function matchReading(verb, tokens, index) {
|
|
|
205
228
|
host: m[1] === 'gh' ? 'github' : 'gitlab',
|
|
206
229
|
kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
|
|
207
230
|
verb: /** @type {'create'|'new'} */ (m[3]),
|
|
231
|
+
via: /** @type {'cli'} */ ('cli'),
|
|
208
232
|
};
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A REST path whose LAST segment is `issues` — the issue COLLECTION endpoint,
|
|
237
|
+
* which is the only one a POST creates an issue on.
|
|
238
|
+
*
|
|
239
|
+
* Deliberately anchored at the end (`?` allowed for a query string): the
|
|
240
|
+
* sub-resources `.../issues/12/notes` and `.../issues/12` are a comment and an
|
|
241
|
+
* update, neither of which creates an issue. Matching them would charge the cap
|
|
242
|
+
* for a note.
|
|
243
|
+
*/
|
|
244
|
+
const ISSUE_COLLECTION_PATH = /(^|\/)issues(\?|$)/;
|
|
245
|
+
|
|
246
|
+
/** `-f` / `-F` / `--field` / `--raw-field` — the payload flags both CLIs take. */
|
|
247
|
+
const FIELD_FLAGS = new Set(['-f', '-F', '--field', '--raw-field']);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Recognise the REST-API route to issue creation: `gh api` / `glab api`
|
|
251
|
+
* against an `/issues` collection path (#1163 BUG-2).
|
|
252
|
+
*
|
|
253
|
+
* ## Why this is not an optional extra
|
|
254
|
+
*
|
|
255
|
+
* Measured 2026-09-09 over a 13-shape census of the matcher: every `api` form
|
|
256
|
+
* was a MISS, i.e. a COMPLETE silent bypass of the issue-budget cap and of the
|
|
257
|
+
* templates-first gate —
|
|
258
|
+
*
|
|
259
|
+
* glab api --method POST projects/1/issues -f title=X → isIssueCreate false
|
|
260
|
+
* gh api repos/o/r/issues -f title=X → isIssueCreate false
|
|
261
|
+
* gh api -X POST repos/o/r/issues -f title=X → isIssueCreate false
|
|
262
|
+
*
|
|
263
|
+
* All three file a real issue. The subcommand route (`glab issue create`) was
|
|
264
|
+
* gated from the start, so an agent that hit the cap could reach the same
|
|
265
|
+
* effect through `api` with no counter moving at all.
|
|
266
|
+
*
|
|
267
|
+
* ## The narrow shape (guard-design: widen the matcher, not the bypass)
|
|
268
|
+
*
|
|
269
|
+
* A LIST call must not match — `gh api repos/o/r/issues` is the single most
|
|
270
|
+
* common read in this repo's own tooling, and charging it would make the cap
|
|
271
|
+
* fire on reads. So the accepted shape is: verb `gh`/`glab`, next token `api`,
|
|
272
|
+
* an `/issues` COLLECTION path somewhere in the argument list, AND either
|
|
273
|
+
* - an explicit POST method (`--method POST`, `--method=POST`, `-X POST`,
|
|
274
|
+
* `-XPOST`), or
|
|
275
|
+
* - a `title=` payload field (`-f title=…`), which only a create carries.
|
|
276
|
+
* An explicit NON-POST method (`--method GET`, `-X PATCH`) is a hard NO-MATCH
|
|
277
|
+
* even when a `title=` field is present: an update is not a creation.
|
|
278
|
+
*
|
|
279
|
+
* `--help` short-circuits here exactly as it does for the subcommand route.
|
|
280
|
+
*
|
|
281
|
+
* NAMED CEILING (BV-004): the payload flags are read as `-f title=…` token
|
|
282
|
+
* pairs and `--field=title=…` is NOT recognised (neither CLI accepts that
|
|
283
|
+
* spelling today). A create whose whole body arrives via `--input -` on stdin
|
|
284
|
+
* matches only through the explicit POST method, which is the shape both CLIs
|
|
285
|
+
* require for that form. Revisit if a census of real create calls shows an
|
|
286
|
+
* `api` shape reaching neither condition.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} verb — basename-normalised verb from resolveSegmentVerb
|
|
289
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens — the whole statement
|
|
290
|
+
* @param {number} index — token index the verb resolved to
|
|
291
|
+
* @returns {{ host: 'github'|'gitlab', kind: 'issue', verb: 'create', via: 'api' } | null}
|
|
292
|
+
*/
|
|
293
|
+
function matchApiReading(verb, tokens, index) {
|
|
294
|
+
if (verb !== 'gh' && verb !== 'glab') return null;
|
|
295
|
+
const sub = tokens[index + 1];
|
|
296
|
+
if (!sub || sub.quoted || sub.text !== 'api') return null;
|
|
297
|
+
|
|
298
|
+
let method = null;
|
|
299
|
+
let hasTitleField = false;
|
|
300
|
+
let issuePath = false;
|
|
301
|
+
|
|
302
|
+
for (let i = index + 2; i < tokens.length; i++) {
|
|
303
|
+
const tok = tokens[i];
|
|
304
|
+
const text = tok.text;
|
|
305
|
+
if (!tok.quoted && text === '--help') return null;
|
|
306
|
+
if (!tok.quoted && (text === '--method' || text === '-X')) {
|
|
307
|
+
method = tokens[i + 1]?.text ?? '';
|
|
308
|
+
i += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!tok.quoted && text.startsWith('--method=')) {
|
|
312
|
+
method = text.slice('--method='.length);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!tok.quoted && text.startsWith('-X') && text.length > 2) {
|
|
316
|
+
method = text.slice(2);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!tok.quoted && FIELD_FLAGS.has(text)) {
|
|
320
|
+
const value = tokens[i + 1]?.text ?? '';
|
|
321
|
+
if (value.startsWith('title=')) hasTitleField = true;
|
|
322
|
+
i += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// A path is an operand, quoted or not — `gh api "repos/o/r/issues"` is the
|
|
326
|
+
// same call as the bare form, and quoting an operand is not concealment the
|
|
327
|
+
// way quoting a whole COMMAND is.
|
|
328
|
+
if (ISSUE_COLLECTION_PATH.test(text)) issuePath = true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!issuePath) return null;
|
|
332
|
+
if (method !== null) {
|
|
333
|
+
if (!/^post$/i.test(method)) return null;
|
|
334
|
+
} else if (!hasTitleField) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return { host: verb === 'gh' ? 'github' : 'gitlab', kind: 'issue', verb: 'create', via: 'api' };
|
|
338
|
+
}
|
|
339
|
+
|
|
211
340
|
/**
|
|
212
341
|
* Test one statement's token array against {@link CREATE_REGEX}, reading the
|
|
213
342
|
* head at the RESOLVED verb index rather than at token 0 (#1145).
|
|
@@ -233,7 +362,9 @@ function matchStatement(tokens) {
|
|
|
233
362
|
}
|
|
234
363
|
for (const reading of [resolved, resolved.alt]) {
|
|
235
364
|
if (!reading || typeof reading.verb !== 'string' || reading.index < 0) continue;
|
|
236
|
-
const shape =
|
|
365
|
+
const shape =
|
|
366
|
+
matchReading(reading.verb, tokens, reading.index) ??
|
|
367
|
+
matchApiReading(reading.verb, tokens, reading.index);
|
|
237
368
|
if (shape) return shape;
|
|
238
369
|
}
|
|
239
370
|
return null;
|
|
@@ -267,9 +398,274 @@ function findCreateStatement(command) {
|
|
|
267
398
|
* `null` when no statement in the command is a `gh`/`glab` create/new call.
|
|
268
399
|
*/
|
|
269
400
|
export function matchVcsCreate(command) {
|
|
270
|
-
|
|
401
|
+
const shape = findCreateStatement(command)?.shape;
|
|
402
|
+
if (!shape) return null;
|
|
403
|
+
// The `via` marker is deliberately NOT part of THIS return shape: the sibling
|
|
404
|
+
// suite `tests/unit/hook-issue-budget.test.mjs` pins it by `toEqual` on the
|
|
405
|
+
// exact three keys, and a fourth key would fail those assertions without any
|
|
406
|
+
// behaviour changing. Consumers that need the route ask
|
|
407
|
+
// {@link findIssueCreateStatements}, which carries it.
|
|
408
|
+
return { host: shape.host, kind: shape.kind, verb: shape.verb };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Read the `--title` (subcommand route) or `-f title=…` (api route) value off
|
|
413
|
+
* ONE statement's tokens.
|
|
414
|
+
*
|
|
415
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
416
|
+
* @returns {string|null}
|
|
417
|
+
*/
|
|
418
|
+
function titleFromTokens(tokens) {
|
|
419
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
420
|
+
const text = tokens[i].text;
|
|
421
|
+
if (text === '--title') {
|
|
422
|
+
const value = tokens[i + 1]?.text;
|
|
423
|
+
return typeof value === 'string' ? value.trim() || null : null;
|
|
424
|
+
}
|
|
425
|
+
if (text.startsWith('--title=')) {
|
|
426
|
+
return text.slice('--title='.length).trim() || null;
|
|
427
|
+
}
|
|
428
|
+
if (FIELD_FLAGS.has(text)) {
|
|
429
|
+
const value = tokens[i + 1]?.text ?? '';
|
|
430
|
+
if (value.startsWith('title=')) return value.slice('title='.length).trim() || null;
|
|
431
|
+
i += 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Per-host CLI flag names for the non-title fields of an issue-create
|
|
439
|
+
* statement (#1314). Verified against the CLIs' own `--help` (2026-09-12):
|
|
440
|
+
* glab issue create: `-t --title`, `-d --description`, `--description-file`, `-R --repo`
|
|
441
|
+
* gh issue create: `-t, --title`, `-b, --body`, `-F, --body-file`, `-R, --repo`
|
|
442
|
+
* Read only on the subcommand route — on the api route `-F` is a field flag.
|
|
443
|
+
*/
|
|
444
|
+
const CLI_FLAGS = {
|
|
445
|
+
gitlab: { description: ['-d', '--description'], descriptionFile: ['--description-file'], repo: ['-R', '--repo'] },
|
|
446
|
+
github: { description: ['-b', '--body'], descriptionFile: ['-F', '--body-file'], repo: ['-R', '--repo'] },
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** `$(cat <path>)` as ONE value — the only command substitution resolved. */
|
|
450
|
+
const CAT_SUBST_RE = /^\$\(cat\s+(\S+)\)$/;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Read the value of a flag in `names` (`--x v`, `--x=v`, `-x v`). When the flag
|
|
454
|
+
* repeats, the LAST value wins — the pflag semantics `gh`/`glab` apply.
|
|
455
|
+
* An UNQUOTED `$(cat p)` is split by the lexer into `$(cat` + `p)`; that pair is
|
|
456
|
+
* rejoined so the substitution survives, in both the `--x $(cat p)` and the
|
|
457
|
+
* `--x=$(cat p)` spelling.
|
|
458
|
+
*
|
|
459
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
460
|
+
* @param {string[]} names
|
|
461
|
+
* @returns {string|null}
|
|
462
|
+
*/
|
|
463
|
+
function flagValue(tokens, names) {
|
|
464
|
+
let found = null;
|
|
465
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
466
|
+
const text = tokens[i].text;
|
|
467
|
+
for (const name of names) {
|
|
468
|
+
let value;
|
|
469
|
+
if (text === name) {
|
|
470
|
+
value = tokens[i + 1]?.text;
|
|
471
|
+
if (typeof value !== 'string') return found;
|
|
472
|
+
i += 1;
|
|
473
|
+
} else if (name.startsWith('--') && text.startsWith(`${name}=`)) {
|
|
474
|
+
value = text.slice(name.length + 1);
|
|
475
|
+
} else {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (value === '$(cat' && typeof tokens[i + 1]?.text === 'string') {
|
|
479
|
+
value = `$(cat ${tokens[i + 1].text}`;
|
|
480
|
+
i += 1;
|
|
481
|
+
}
|
|
482
|
+
found = value;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return found;
|
|
271
487
|
}
|
|
272
488
|
|
|
489
|
+
/**
|
|
490
|
+
* The paths of every `$(cat <path>)` that sits inside SINGLE quotes in the RAW
|
|
491
|
+
* command. The lexer marks single- and double-quoted tokens alike, but only
|
|
492
|
+
* the double-quoted (or bare) form is expanded by the shell — inside single
|
|
493
|
+
* quotes `gh`/`glab` receive the literal text, so reading that file would park
|
|
494
|
+
* content the CLI never saw (`-d '$(cat .env)'`).
|
|
495
|
+
*
|
|
496
|
+
* @param {string} command — the raw command, quotes intact
|
|
497
|
+
* @returns {Set<string>}
|
|
498
|
+
*/
|
|
499
|
+
function singleQuotedCatPaths(command) {
|
|
500
|
+
const out = new Set();
|
|
501
|
+
let quote = null;
|
|
502
|
+
let span = '';
|
|
503
|
+
for (let i = 0; i < command.length; i++) {
|
|
504
|
+
const c = command[i];
|
|
505
|
+
if (quote === "'") {
|
|
506
|
+
if (c === "'") {
|
|
507
|
+
for (const m of span.matchAll(/\$\(cat\s+(\S+)\)/g)) out.add(m[1]);
|
|
508
|
+
quote = null;
|
|
509
|
+
span = '';
|
|
510
|
+
} else {
|
|
511
|
+
span += c;
|
|
512
|
+
}
|
|
513
|
+
} else if (c === '\\') {
|
|
514
|
+
i += 1; // an escaped char opens no quote
|
|
515
|
+
} else if (quote === '"') {
|
|
516
|
+
if (c === '"') quote = null;
|
|
517
|
+
} else if (c === "'" || c === '"') {
|
|
518
|
+
quote = c;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Title, description, description file and target repo of ONE create
|
|
526
|
+
* statement (#1314) — what a parked overflow record needs to be re-filed.
|
|
527
|
+
* Additive to {@link titleFromTokens}: its `--title` / `-f title=` reading is
|
|
528
|
+
* kept verbatim; `-t` is added on the subcommand route only.
|
|
529
|
+
*
|
|
530
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
531
|
+
* @param {{ host: string, via: string }} shape
|
|
532
|
+
* @param {Set<string>} literalCatPaths — from {@link singleQuotedCatPaths}
|
|
533
|
+
* @returns {{ title: string|null, description: string|null,
|
|
534
|
+
* descriptionFile: string|null, repo: string|null }}
|
|
535
|
+
*/
|
|
536
|
+
function fieldsFromTokens(tokens, shape, literalCatPaths) {
|
|
537
|
+
let title = titleFromTokens(tokens);
|
|
538
|
+
const flags = shape.via === 'cli' ? CLI_FLAGS[shape.host] : undefined;
|
|
539
|
+
if (!flags) return { title, description: null, descriptionFile: null, repo: null };
|
|
540
|
+
if (title === null) title = flagValue(tokens, ['-t'])?.trim() || null;
|
|
541
|
+
let description = flagValue(tokens, flags.description);
|
|
542
|
+
let descriptionFile = flagValue(tokens, flags.descriptionFile);
|
|
543
|
+
// `-` means stdin (or an editor for glab -d) — nothing a later read can reach.
|
|
544
|
+
if (descriptionFile === '-') descriptionFile = null;
|
|
545
|
+
if (description === '-') description = null;
|
|
546
|
+
// A single-quoted `$(cat p)` stays the literal text the CLI files.
|
|
547
|
+
const subst = typeof description === 'string' ? CAT_SUBST_RE.exec(description) : null;
|
|
548
|
+
if (subst && !literalCatPaths.has(subst[1])) {
|
|
549
|
+
description = null;
|
|
550
|
+
descriptionFile ??= subst[1];
|
|
551
|
+
}
|
|
552
|
+
const fileSubst = typeof descriptionFile === 'string' ? CAT_SUBST_RE.exec(descriptionFile) : null;
|
|
553
|
+
if (fileSubst) descriptionFile = literalCatPaths.has(fileSubst[1]) ? null : fileSubst[1];
|
|
554
|
+
return { title, description, descriptionFile, repo: flagValue(tokens, flags.repo) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* EVERY issue-create statement in the command chain, in source order (#1163
|
|
559
|
+
* BUG-1).
|
|
560
|
+
*
|
|
561
|
+
* ## Why the callers cannot keep using {@link isIssueCreate} alone
|
|
562
|
+
*
|
|
563
|
+
* `isIssueCreate` answers "does this command create an issue?" — a BOOLEAN,
|
|
564
|
+
* which is exactly one issue short of what a QUANTITY gate needs. Measured
|
|
565
|
+
* 2026-09-09 against `hooks/pre-bash-issue-budget.mjs` before this change:
|
|
566
|
+
* `glab issue create --title A && glab issue create --title B` charged the cap
|
|
567
|
+
* ONCE for TWO issues, because the hook called `chargeIssueBudget` once per
|
|
568
|
+
* Bash tool call with the whole command string. The segmentation to answer it
|
|
569
|
+
* correctly was already here — nothing consumed it per statement.
|
|
570
|
+
*
|
|
571
|
+
* Each record carries the tokens the caller must judge (never the whole
|
|
572
|
+
* command): a chain may mix an exempt create with a chargeable one, and
|
|
573
|
+
* classifying the exemption on the joined command text would exempt BOTH —
|
|
574
|
+
* the same class of hole as the bypass-scoping regression documented on
|
|
575
|
+
* {@link matchesBypass}.
|
|
576
|
+
*
|
|
577
|
+
* `text` is the statement rebuilt from its tokens (quotes already resolved by
|
|
578
|
+
* the lexer, arguments joined by single spaces). It is a CLASSIFICATION INPUT
|
|
579
|
+
* for `classifyExemption`, never something to re-execute.
|
|
580
|
+
*
|
|
581
|
+
* `cwdChanged` is true when an EARLIER statement of the same chain is a
|
|
582
|
+
* `cd`/`pushd`/`popd`: a relative description-file path is then relative to a
|
|
583
|
+
* directory this hook cannot know, so `buildOverflowRecord` must not resolve it.
|
|
584
|
+
*
|
|
585
|
+
* @param {string} command
|
|
586
|
+
* @returns {Array<{ shape: { host: string, kind: string, verb: string, via: string },
|
|
587
|
+
* tokens: Array<{ text: string, quoted: boolean }>,
|
|
588
|
+
* text: string,
|
|
589
|
+
* title: string|null,
|
|
590
|
+
* description: string|null,
|
|
591
|
+
* descriptionFile: string|null,
|
|
592
|
+
* repo: string|null,
|
|
593
|
+
* cwdChanged: boolean }>}
|
|
594
|
+
*/
|
|
595
|
+
export function findIssueCreateStatements(command) {
|
|
596
|
+
const out = [];
|
|
597
|
+
let literalCatPaths = null;
|
|
598
|
+
let cwdChanged = false;
|
|
599
|
+
for (const tokens of statementsOf(command)) {
|
|
600
|
+
const shape = matchStatement(tokens);
|
|
601
|
+
if (!shape || shape.kind !== 'issue') {
|
|
602
|
+
if (!cwdChanged && tokens.length > 0) {
|
|
603
|
+
try {
|
|
604
|
+
cwdChanged = CWD_VERBS.has(resolveSegmentVerb(tokens).verb);
|
|
605
|
+
} catch {
|
|
606
|
+
cwdChanged = true; // unknown → do not trust a relative path
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
literalCatPaths ??= singleQuotedCatPaths(command);
|
|
612
|
+
out.push({
|
|
613
|
+
shape,
|
|
614
|
+
tokens,
|
|
615
|
+
text: tokens.map((t) => t.text).join(' '),
|
|
616
|
+
...fieldsFromTokens(tokens, shape, literalCatPaths),
|
|
617
|
+
cwdChanged,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Do the issue-create statements account for the WHOLE command? (#1347)
|
|
625
|
+
*
|
|
626
|
+
* ## Why a refund needs this and a charge does not
|
|
627
|
+
*
|
|
628
|
+
* The charge is per statement and runs BEFORE the command, so it never has to
|
|
629
|
+
* ask what else is in the chain. The refund runs AFTER, and the harness reports
|
|
630
|
+
* ONE exit code for the whole Bash call — so a failure signal on
|
|
631
|
+
* `glab issue create --title X && false` says nothing about the create. Measured
|
|
632
|
+
* 2026-09-13 against the first cut of `hooks/post-bash-issue-budget-refund.mjs`:
|
|
633
|
+
* that exact command CREATES the issue, exits 1, and one slot was handed back.
|
|
634
|
+
* Refunding a create whose failure is not attributable to it is a cap drain, so
|
|
635
|
+
* the refund hook asks this question first and no-ops when the answer is false.
|
|
636
|
+
*
|
|
637
|
+
* True only when the command has at least one issue-create statement and EVERY
|
|
638
|
+
* statement of the chain is one. A chain that mixes a create with anything else
|
|
639
|
+
* — including a benign-looking `cd`, `echo`, or `true` — is not attributable
|
|
640
|
+
* here, which is the fail-CLOSED direction: the slot stays spent.
|
|
641
|
+
*
|
|
642
|
+
* NAMED CEILING (BV-004): the most common agent shape `cd <repo> && glab issue
|
|
643
|
+
* create …` is therefore NOT refundable, even though a failing `cd` provably
|
|
644
|
+
* means the create never ran. Admitting a prefix class would also admit a
|
|
645
|
+
* TRAILING one under the same argument, and a trailing statement's failure is
|
|
646
|
+
* exactly the drain above. Revisit if the refund rate measured over
|
|
647
|
+
* `.orchestrator/runtime/issue-budget/<hash>.json` charges shows the `cd` shape
|
|
648
|
+
* dominating; the safe widening is a LEADING-only allowance for `CWD_VERBS`,
|
|
649
|
+
* never a general one.
|
|
650
|
+
*
|
|
651
|
+
* @param {string} command
|
|
652
|
+
* @returns {boolean}
|
|
653
|
+
*/
|
|
654
|
+
export function statementsCoverWholeCommand(command) {
|
|
655
|
+
const all = statementsOf(command);
|
|
656
|
+
if (all.length === 0) return false;
|
|
657
|
+
let creates = 0;
|
|
658
|
+
for (const tokens of all) {
|
|
659
|
+
const shape = matchStatement(tokens);
|
|
660
|
+
if (!shape || shape.kind !== 'issue') return false;
|
|
661
|
+
creates += 1;
|
|
662
|
+
}
|
|
663
|
+
return creates > 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Builtins that move the shell's working directory for later statements. */
|
|
667
|
+
const CWD_VERBS = new Set(['cd', 'pushd', 'popd']);
|
|
668
|
+
|
|
273
669
|
/**
|
|
274
670
|
* Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
|
|
275
671
|
* Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
|
|
@@ -463,16 +859,5 @@ export function extractTitle(command) {
|
|
|
463
859
|
|
|
464
860
|
const found = findCreateStatement(command);
|
|
465
861
|
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;
|
|
862
|
+
return titleFromTokens(tokens);
|
|
478
863
|
}
|
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* Runs before G6 so a deliberate out-of-repo grant (e.g. a vault path)
|
|
23
23
|
* is reachable at all — G6 would otherwise deny every out-of-repo path
|
|
24
24
|
* without ever consulting allowedPaths. See matchesAbsoluteAllowlist.
|
|
25
|
+
* G5c (#1295) out-of-root carveout for THIS repo's Claude Code auto-memory
|
|
26
|
+
* directory (`~/.claude/projects/<encoded-repo-path>/memory/`). Harness-
|
|
27
|
+
* owned, lives outside the working copy, cannot collide with any wave
|
|
28
|
+
* scope. Evaluated inside G6's out-of-root branch only; a SIBLING repo's
|
|
29
|
+
* memory dir and every other out-of-repo path stay denied.
|
|
25
30
|
* G6 resolved path inside project root
|
|
26
31
|
* G7 relative path matches an allowedPaths pattern
|
|
27
32
|
* G8 (all passed) → allow
|
|
@@ -432,6 +437,73 @@ async function main() {
|
|
|
432
437
|
|
|
433
438
|
// Gate 6: path must be inside the project root
|
|
434
439
|
if (!isPathInside(resolvedPath, projectRoot)) {
|
|
440
|
+
// Gate 5c (#1295) — THIS repo's harness auto-memory directory.
|
|
441
|
+
//
|
|
442
|
+
// Claude Code writes its own auto-memory (`MEMORY.md` + per-fact files)
|
|
443
|
+
// OUTSIDE the working copy, at
|
|
444
|
+
// `~/.claude/projects/<encoded-repo-path>/memory/`. Every wave manifest
|
|
445
|
+
// therefore blocked the coordinator's memory writes until the manifest was
|
|
446
|
+
// torn down — six sessions in a consumer repo re-documented the same
|
|
447
|
+
// workaround (bewerbungs-assistent#307, retro 2026-09-09). That directory
|
|
448
|
+
// is harness-owned and cannot collide with ANY wave file scope, so it is
|
|
449
|
+
// always-allowed territory; it is the single out-of-repo carveout here.
|
|
450
|
+
//
|
|
451
|
+
// NARROWNESS (security boundary — no wider allow than this one directory):
|
|
452
|
+
// - Only the memory dir of THIS repo, as the harness names it. That name
|
|
453
|
+
// is NOT unique: `encodeProjectDir()` maps both `/` and `.` to `-`, so
|
|
454
|
+
// `/x/a.b` and `/x/a-b` share one memory dir — a harness-level collision
|
|
455
|
+
// this gate inherits and cannot narrow. Every OTHER sibling repo's
|
|
456
|
+
// memory dir stays denied.
|
|
457
|
+
// - Containment is exact-prefix on the REALPATH-resolved candidate, so
|
|
458
|
+
// `..` segments and symlink tricks cannot widen it.
|
|
459
|
+
// - Evaluated only on the out-of-root branch: the in-repo gates (Gate 7,
|
|
460
|
+
// Discovery's `allowedPaths: []` deny-all) are untouched.
|
|
461
|
+
//
|
|
462
|
+
// Encoder REUSED, never re-written (BV-001 rung 2): `encodeProjectDir()`
|
|
463
|
+
// from `scripts/lib/wave-transcript-tail.mjs` is the repo's one encoder for
|
|
464
|
+
// `<encoded-repo-path>`. Bound LAZILY here rather than in `bootstrap()` —
|
|
465
|
+
// same pattern as the G3b event import above — so the happy path pays no
|
|
466
|
+
// module load, and an import failure falls through to the deny below
|
|
467
|
+
// (fail-closed, byte-identical to the pre-#1295 behaviour).
|
|
468
|
+
//
|
|
469
|
+
// CEILING (BV-004): `CLAUDE_CONFIG_DIR` is NOT honoured, because nothing in
|
|
470
|
+
// this codebase honours it today (`rg -n "CLAUDE_CONFIG_DIR" scripts hooks`
|
|
471
|
+
// → 0 matches, 2026-09-13) and `wave-transcript-tail.mjs` resolves the same
|
|
472
|
+
// substrate from `homedir()`. Revisit together with that resolver if the
|
|
473
|
+
// harness config dir ever becomes relocatable here.
|
|
474
|
+
// CALLER DISCRIMINATION (#1352): the carve-out is the COORDINATOR's, never a
|
|
475
|
+
// dispatched wave agent's. See {@link classifyCaller} for the measurement.
|
|
476
|
+
// A subagent falls through to the gates below — under a manifest that does
|
|
477
|
+
// not grant the path (Discovery's `allowedPaths: []`) that is a DENY.
|
|
478
|
+
const memoryDirs = await ownMemoryDirs(projectRootRaw, projectRoot);
|
|
479
|
+
const caller = classifyCaller(input);
|
|
480
|
+
if (caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
|
|
481
|
+
// One event per decision point, awaited before emitAllow() —
|
|
482
|
+
// emitAllow() calls process.exit(), which would discard a pending append.
|
|
483
|
+
try {
|
|
484
|
+
const { emitEvent } = await import('../scripts/lib/events.mjs');
|
|
485
|
+
await emitEvent(
|
|
486
|
+
'orchestrator.scope.memory_dir_allowed',
|
|
487
|
+
{
|
|
488
|
+
hook: HOOK_NAME,
|
|
489
|
+
manifest: scopePath,
|
|
490
|
+
wave: scope.wave,
|
|
491
|
+
file_path: resolvedPath,
|
|
492
|
+
// HR-105: the fail-OPEN case must be COUNTABLE, or the ambiguity is
|
|
493
|
+
// unfalsifiable — and it takes TWO values to be countable.
|
|
494
|
+
// `'absent'` = no `agent_id` key at all (the harness's own contract
|
|
495
|
+
// for a main-thread call); `'malformed'` = an `agent_id` key that is
|
|
496
|
+
// PRESENT but unusable (number/object/array/blank string), which
|
|
497
|
+
// would otherwise be indistinguishable from the legitimate case.
|
|
498
|
+
// `'coordinator'` = `agent_type` without `agent_id`.
|
|
499
|
+
discriminator: caller,
|
|
500
|
+
},
|
|
501
|
+
{ repoRoot: projectRoot },
|
|
502
|
+
);
|
|
503
|
+
} catch { /* observability is best-effort — never blocks the decision */ }
|
|
504
|
+
return emitAllow();
|
|
505
|
+
}
|
|
506
|
+
|
|
435
507
|
const reason = `Scope violation: path outside project root`;
|
|
436
508
|
const suggestion = suggest(filePath);
|
|
437
509
|
return enforcement === 'strict'
|
|
@@ -500,6 +572,135 @@ async function mtimeMsOf(file) {
|
|
|
500
572
|
}
|
|
501
573
|
}
|
|
502
574
|
|
|
575
|
+
/**
|
|
576
|
+
* #1295 — the Claude Code auto-memory directories that belong to THIS repo.
|
|
577
|
+
*
|
|
578
|
+
* Returns at most two paths, both naming the SAME repo: the encoding of the
|
|
579
|
+
* project root as the harness saw it (`CLAUDE_PROJECT_DIR` / cwd) and — when it
|
|
580
|
+
* differs — the encoding of its realpath. Both are needed because Claude Code
|
|
581
|
+
* encodes the path it was LAUNCHED with, while this hook compares against the
|
|
582
|
+
* canonical root (on macOS `/tmp` → `/private/tmp`). Two encodings of one repo
|
|
583
|
+
* is not a wider grant: a sibling repo's root encodes to neither.
|
|
584
|
+
*
|
|
585
|
+
* Returns `[]` when the encoder cannot be loaded, so the caller falls through to
|
|
586
|
+
* its deny (fail-closed).
|
|
587
|
+
*
|
|
588
|
+
* @param {string} projectRootRaw — project root as resolved from env/cwd
|
|
589
|
+
* @param {string} projectRoot — its realpath
|
|
590
|
+
* @returns {Promise<string[]>}
|
|
591
|
+
*/
|
|
592
|
+
async function ownMemoryDirs(projectRootRaw, projectRoot) {
|
|
593
|
+
try {
|
|
594
|
+
const [{ encodeProjectDir }, { homedir }] = await Promise.all([
|
|
595
|
+
import('../scripts/lib/wave-transcript-tail.mjs'),
|
|
596
|
+
import('node:os'),
|
|
597
|
+
]);
|
|
598
|
+
const home = homedir();
|
|
599
|
+
if (!home) return [];
|
|
600
|
+
// The candidate arrives REALPATH-resolved (SECURITY-REQ-03), so the home
|
|
601
|
+
// side must be too — otherwise the macOS `/tmp` → `/private/tmp` symlink
|
|
602
|
+
// alone makes every comparison miss.
|
|
603
|
+
let homeReal = home;
|
|
604
|
+
try {
|
|
605
|
+
homeReal = await fs.realpath(home);
|
|
606
|
+
} catch { /* non-existent home — the raw form is all there is */ }
|
|
607
|
+
const homes = new Set([home, homeReal]);
|
|
608
|
+
const roots = new Set(
|
|
609
|
+
[projectRootRaw, projectRoot].filter((p) => typeof p === 'string' && p.length > 0),
|
|
610
|
+
);
|
|
611
|
+
const dirs = [];
|
|
612
|
+
for (const h of homes) {
|
|
613
|
+
for (const root of roots) {
|
|
614
|
+
dirs.push(path.join(h, '.claude', 'projects', encodeProjectDir(root), 'memory'));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return dirs;
|
|
618
|
+
} catch {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Exact-prefix containment: is `candidate` `dir` itself or a descendant of it?
|
|
625
|
+
*
|
|
626
|
+
* Both sides are `path.resolve`d first, so `..` segments collapse before the
|
|
627
|
+
* comparison and cannot escape `dir`. The `path.sep` suffix is load-bearing —
|
|
628
|
+
* without it `<dir>-evil/x` would match the prefix of `<dir>`.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} candidate
|
|
631
|
+
* @param {string} dir
|
|
632
|
+
* @returns {boolean}
|
|
633
|
+
*/
|
|
634
|
+
function isInsideDir(candidate, dir) {
|
|
635
|
+
const base = path.resolve(dir);
|
|
636
|
+
const target = path.resolve(candidate);
|
|
637
|
+
return target === base || target.startsWith(base + path.sep);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Classify a PreToolUse payload as coming from a dispatched SUBAGENT or from the
|
|
642
|
+
* main (coordinator) thread — the discriminator Gate 5c's memory carve-out needs
|
|
643
|
+
* (#1352).
|
|
644
|
+
*
|
|
645
|
+
* MEASURED, not assumed (2026-09-13, Claude Code 2.1.270, `strings` over
|
|
646
|
+
* `~/.local/share/claude/versions/2.1.270`):
|
|
647
|
+
* - The base hook-input builder shared by EVERY event is
|
|
648
|
+
* `{session_id: e.id, transcript_path: yf(e.id), cwd, …, agent_id: s?.agentId,
|
|
649
|
+
* agent_type: d}` — PreToolUse spreads it verbatim
|
|
650
|
+
* (`{...Na(s.session,Z(),d,s), hook_event_name:"PreToolUse", tool_name, …}`).
|
|
651
|
+
* - The harness's own schema documents the field: *"Subagent identifier. Present
|
|
652
|
+
* only when the hook fires from within a subagent (e.g., a tool called by an
|
|
653
|
+
* AgentTool worker). Absent for the main thread, even in --agent sessions. Use
|
|
654
|
+
* this field (not agent_type) to distinguish subagent calls from main-thread
|
|
655
|
+
* calls."*
|
|
656
|
+
* - Two candidates are REFUTED by the same source: `transcript_path` is
|
|
657
|
+
* `yf(session.id)` — session-derived, hence IDENTICAL for coordinator and
|
|
658
|
+
* subagent; and `CLAUDE_CODE_CHILD_SESSION=1` appears in a main-thread `env`
|
|
659
|
+
* dump (`isSidechain:false`), so it marks the CLI process, not the caller.
|
|
660
|
+
*
|
|
661
|
+
* `agent_type` is deliberately a WEAKER witness, used only in the negative
|
|
662
|
+
* direction: per the same schema it is present on the main thread of an `--agent`
|
|
663
|
+
* session WITHOUT `agent_id`, so `agent_type`-without-`agent_id` positively names
|
|
664
|
+
* a coordinator, while `agent_type` alone can never name a subagent.
|
|
665
|
+
*
|
|
666
|
+
* FAIL-OPEN on ambiguity, deliberately (BV-004 ceiling): a payload this function
|
|
667
|
+
* cannot read as a subagent keeps the pre-#1352 ALLOW, because fail-closed here
|
|
668
|
+
* would deny the coordinator's own memory writes and break `/close`. The value is
|
|
669
|
+
* emitted on the event so the ambiguity is countable (HR-105) — but countability
|
|
670
|
+
* needs TWO fail-open values, not one:
|
|
671
|
+
*
|
|
672
|
+
* - `'absent'` — NO `agent_id` key in the payload at all. The harness's own
|
|
673
|
+
* documented shape for a main-thread call; the legitimate case.
|
|
674
|
+
* - `'malformed'` — an `agent_id` key IS present and carries something this
|
|
675
|
+
* function cannot use as an identity (number, object, array,
|
|
676
|
+
* empty/whitespace string). Folding this into `'absent'` made a
|
|
677
|
+
* PRESENT-but-unusable marker byte-identical to a genuine
|
|
678
|
+
* main-thread call, so a future harness or bridge sending a
|
|
679
|
+
* numeric or object agent id would hand EVERY subagent the
|
|
680
|
+
* carve-out with nothing in the ledger to show it. Empty and
|
|
681
|
+
* whitespace-only strings belong here, not in `'absent'`: the
|
|
682
|
+
* key is present, so the sender believed it was identifying a
|
|
683
|
+
* subagent, and that is exactly the case this value separates.
|
|
684
|
+
*
|
|
685
|
+
* Only `agent_id` can produce `'malformed'`. `agent_type` is used solely in the
|
|
686
|
+
* positive coordinator direction and can never hide a subagent, so an unusable
|
|
687
|
+
* `agent_type` without an `agent_id` stays `'absent'`.
|
|
688
|
+
*
|
|
689
|
+
* Revisit if `discriminator: 'malformed'` appears at all, or if `'absent'`
|
|
690
|
+
* dominates the records on a harness that DOES dispatch subagents.
|
|
691
|
+
*
|
|
692
|
+
* @param {Record<string, unknown>} input Parsed PreToolUse stdin payload.
|
|
693
|
+
* @returns {'subagent'|'coordinator'|'malformed'|'absent'}
|
|
694
|
+
*/
|
|
695
|
+
function classifyCaller(input) {
|
|
696
|
+
const agentId = input?.agent_id;
|
|
697
|
+
if (typeof agentId === 'string' && agentId.trim() !== '') return 'subagent';
|
|
698
|
+
if (agentId !== undefined && agentId !== null) return 'malformed';
|
|
699
|
+
const agentType = input?.agent_type;
|
|
700
|
+
if (typeof agentType === 'string' && agentType.trim() !== '') return 'coordinator';
|
|
701
|
+
return 'absent';
|
|
702
|
+
}
|
|
703
|
+
|
|
503
704
|
const COORDINATOR_CARVEOUT_PATHS = Object.freeze([
|
|
504
705
|
'.claude/STATE.md',
|
|
505
706
|
'.codex/STATE.md',
|