session-orchestrator 5.3.0 → 5.5.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/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -109,6 +109,10 @@
|
|
|
109
109
|
* observed actor could rewrite the gate unreported. Because the file is
|
|
110
110
|
* typically gitignored, `git status` cannot see it anyway — change detection
|
|
111
111
|
* for it is content-hash-based against the snapshot (`formatControlNotice`).
|
|
112
|
+
* Its sibling control state `<state-dir>/filescopes/**` (the per-agent scope
|
|
113
|
+
* declarations, #1020) is likewise not on the list: `computeReport` drops it
|
|
114
|
+
* from the violation set beside the manifest itself, keyed on the ACTIVE
|
|
115
|
+
* manifest's state dir (`isScopeDeclarationPath`, #1027 Pkt 5).
|
|
112
116
|
*
|
|
113
117
|
* The hook's OWN snapshot lives in `$TMPDIR`, never inside the repo, so it is
|
|
114
118
|
* structurally incapable of reporting its own bookkeeping.
|
|
@@ -149,18 +153,24 @@ import os from 'node:os';
|
|
|
149
153
|
import { fileURLToPath } from 'node:url';
|
|
150
154
|
import { createHash } from 'node:crypto';
|
|
151
155
|
import { execFileSync } from 'node:child_process';
|
|
152
|
-
import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
156
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
153
157
|
|
|
154
158
|
import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
|
|
155
|
-
import {
|
|
156
|
-
import {
|
|
159
|
+
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
160
|
+
import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
157
161
|
// #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
|
|
158
162
|
// hooks/enforce-scope.mjs can read the same session clock without a hook->hook
|
|
159
163
|
// import. Re-exported below, so this hook's public surface — and
|
|
160
164
|
// tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
|
|
161
165
|
// is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
|
|
162
166
|
// copies class this repo keeps paying for.
|
|
163
|
-
import {
|
|
167
|
+
import {
|
|
168
|
+
sessionAgeMs,
|
|
169
|
+
PEER_RECORD_PREFIX,
|
|
170
|
+
isPeerRecordId,
|
|
171
|
+
isScopeDeclarationPath,
|
|
172
|
+
findOwnScopeFile,
|
|
173
|
+
} from '../scripts/lib/scope-gate.mjs';
|
|
164
174
|
// #1153 P1 — the same process-local ownership check hooks/enforce-scope.mjs
|
|
165
175
|
// applies at Gate 3b. This hook reads the SAME working-copy `wave-scope.json`,
|
|
166
176
|
// so without it a peer session's manifest drives this session's advisories.
|
|
@@ -313,6 +323,45 @@ export function parsePorcelainZ(raw) {
|
|
|
313
323
|
return out;
|
|
314
324
|
}
|
|
315
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Rebase toplevel-relative porcelain paths onto the SESSION ROOT.
|
|
328
|
+
*
|
|
329
|
+
* `git status --porcelain` names paths relative to the git TOPLEVEL, while
|
|
330
|
+
* `allowedPaths`, the ignore list and the manifest path are relative to the
|
|
331
|
+
* session root — since #1492 the LAUNCH DIR, which in a session started from a
|
|
332
|
+
* repo subdirectory (`<mono>/packages/foo`) sits below the toplevel. Judged
|
|
333
|
+
* unrebased, an in-scope write to `packages/foo/src/new.mjs` failed `src/**`
|
|
334
|
+
* (false positive) and a write to `<mono>/src/evil.mjs` matched it (false
|
|
335
|
+
* negative). A path under the prefix loses it; every other path is outside the
|
|
336
|
+
* project root and is spelled `../…` from the session root — the same category
|
|
337
|
+
* enforce-scope Gate 6 denies, and a spelling no allowedPaths entry can grant
|
|
338
|
+
* (see {@link isOutsideSessionRoot}).
|
|
339
|
+
*
|
|
340
|
+
* @param {string[]} paths toplevel-relative, forward slashes
|
|
341
|
+
* @param {string} prefix session root relative to the toplevel ('' = same dir)
|
|
342
|
+
* @returns {string[]}
|
|
343
|
+
*/
|
|
344
|
+
export function rebaseToSessionRoot(paths, prefix) {
|
|
345
|
+
const clean = typeof prefix === 'string' ? prefix.replace(/\/+$/, '') : '';
|
|
346
|
+
// '' = the session root IS the toplevel; '..'/absolute cannot come from
|
|
347
|
+
// `--show-prefix` and would make the arithmetic meaningless — leave as is.
|
|
348
|
+
if (clean === '' || clean.startsWith('..') || clean.startsWith('/')) return paths;
|
|
349
|
+
const head = `${clean}/`;
|
|
350
|
+
const ups = clean.split('/').map(() => '..').join('/');
|
|
351
|
+
return paths.map((p) => (p.startsWith(head) ? p.slice(head.length) : `${ups}/${p}`));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Is this (session-root-relative) path outside the project root — a `../…`
|
|
356
|
+
* spelling produced by {@link rebaseToSessionRoot}? Never in scope.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} relPath
|
|
359
|
+
* @returns {boolean}
|
|
360
|
+
*/
|
|
361
|
+
export function isOutsideSessionRoot(relPath) {
|
|
362
|
+
return relPath === '..' || relPath.startsWith('../');
|
|
363
|
+
}
|
|
364
|
+
|
|
316
365
|
/**
|
|
317
366
|
* Is a repo-relative path covered by the wave's `allowedPaths`?
|
|
318
367
|
*
|
|
@@ -384,14 +433,17 @@ export function readPeerScopeRecords(stateDir, wave) {
|
|
|
384
433
|
* Which peer record — if any — declared this path?
|
|
385
434
|
*
|
|
386
435
|
* Same matcher as `isInScope`, so a peer's declaration is read exactly the way
|
|
387
|
-
* the wave's own `allowedPaths` are
|
|
436
|
+
* the wave's own `allowedPaths` are — and the same outside-root guard as
|
|
437
|
+
* `computeReport`: a `../…` path is outside the project root, which no peer
|
|
438
|
+
* record can grant any more than `allowedPaths` can (a peer glob over every
|
|
439
|
+
* `.md` file would otherwise file `<mono>/NOTES.md` as an agreed peer write).
|
|
388
440
|
*
|
|
389
441
|
* @param {string} relPath
|
|
390
442
|
* @param {Array<{ id: string, files: string[] }>} peerRecords
|
|
391
443
|
* @returns {string|null} the peer record id, or null
|
|
392
444
|
*/
|
|
393
445
|
export function peerRecordFor(relPath, peerRecords) {
|
|
394
|
-
if (!Array.isArray(peerRecords)) return null;
|
|
446
|
+
if (!Array.isArray(peerRecords) || isOutsideSessionRoot(relPath)) return null;
|
|
395
447
|
for (const record of peerRecords) {
|
|
396
448
|
if (record.files.some((p) => pathMatchesPattern(relPath, p))) return record.id;
|
|
397
449
|
}
|
|
@@ -466,14 +518,22 @@ export function snapshotPathFor(repoRoot) {
|
|
|
466
518
|
export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
|
|
467
519
|
const outOfScope = dirtyPaths
|
|
468
520
|
.filter((p) => !isIgnoredPath(p))
|
|
469
|
-
|
|
521
|
+
// A `../…` path (outside the project root, see rebaseToSessionRoot) is never
|
|
522
|
+
// in scope — not even under a broad glob that would happen to match it.
|
|
523
|
+
.filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths))
|
|
470
524
|
// The wave-scope.json control file is out-of-scope by construction (it is
|
|
471
525
|
// never under allowedPaths) but its CHANGES are reported via the content-hash
|
|
472
526
|
// control-notice path, not here. Excluding it keeps the mtime re-baseline
|
|
473
527
|
// filter from reporting the scope file against ITSELF — its mtime always
|
|
474
528
|
// equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
|
|
475
529
|
// every first run (#938 MED-3 follow-through).
|
|
476
|
-
.filter((p) => p !== scopeRelPath)
|
|
530
|
+
.filter((p) => p !== scopeRelPath)
|
|
531
|
+
// Its sibling control state, `<state-dir>/filescopes/` (the per-agent scope
|
|
532
|
+
// declarations `materialize-wave-scope.mjs` writes once per wave), is no
|
|
533
|
+
// wave territory either — excluded the same way, not via
|
|
534
|
+
// IGNORED_PATH_PATTERNS, so the exemption follows THIS manifest's state dir
|
|
535
|
+
// instead of granting all four harness dirs (#1027 Pkt 5).
|
|
536
|
+
.filter((p) => !isScopeDeclarationPath(p, scopeRelPath));
|
|
477
537
|
|
|
478
538
|
const rebaselined = !snapshot || snapshot.signature !== signature;
|
|
479
539
|
const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
|
|
@@ -515,7 +575,9 @@ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, s
|
|
|
515
575
|
* @returns {string}
|
|
516
576
|
*/
|
|
517
577
|
export function formatMessage(report, allowedCount) {
|
|
518
|
-
const shown = report
|
|
578
|
+
const shown = report
|
|
579
|
+
.slice(0, MAX_REPORTED_PATHS)
|
|
580
|
+
.map((p) => (isOutsideSessionRoot(p) ? `${p} (outside project root)` : p));
|
|
519
581
|
const more = report.length - shown.length;
|
|
520
582
|
const tail = more > 0 ? ` (+${more} more)` : '';
|
|
521
583
|
const msg =
|
|
@@ -608,12 +670,13 @@ export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null
|
|
|
608
670
|
// ---------------------------------------------------------------------------
|
|
609
671
|
|
|
610
672
|
/**
|
|
611
|
-
* Collect the working-tree dirty set
|
|
673
|
+
* Collect the working-tree dirty set, rebased onto the session root
|
|
674
|
+
* ({@link rebaseToSessionRoot}). Returns null when git is unavailable, the
|
|
612
675
|
* directory is not a repo, or the call times out — all of which mean "no signal
|
|
613
676
|
* to report", never a warning.
|
|
614
677
|
*
|
|
615
|
-
* @param {string} repoRoot
|
|
616
|
-
* @returns {string[]|null}
|
|
678
|
+
* @param {string} repoRoot the session root
|
|
679
|
+
* @returns {string[]|null} session-root-relative paths (`../…` = outside it)
|
|
617
680
|
*/
|
|
618
681
|
function readDirtyPaths(repoRoot) {
|
|
619
682
|
try {
|
|
@@ -630,7 +693,16 @@ function readDirtyPaths(repoRoot) {
|
|
|
630
693
|
['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
|
|
631
694
|
{ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
632
695
|
);
|
|
633
|
-
|
|
696
|
+
// Porcelain paths are toplevel-relative; everything they are judged against
|
|
697
|
+
// is session-root-relative. A `.git` AT the session root makes it the
|
|
698
|
+
// toplevel (prefix ''), so the extra spawn is paid only by a session
|
|
699
|
+
// launched from a repo subdirectory.
|
|
700
|
+
const prefix = existsSync(path.join(repoRoot, '.git'))
|
|
701
|
+
? ''
|
|
702
|
+
: execFileSync('git', ['rev-parse', '--show-prefix'], {
|
|
703
|
+
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
|
|
704
|
+
}).trim();
|
|
705
|
+
return rebaseToSessionRoot(parsePorcelainZ(raw), prefix);
|
|
634
706
|
} catch {
|
|
635
707
|
return null;
|
|
636
708
|
}
|
|
@@ -807,7 +879,10 @@ async function main() {
|
|
|
807
879
|
// G2 — only Bash calls carry the bypass risk this hook watches.
|
|
808
880
|
if (input.tool_name !== 'Bash') return;
|
|
809
881
|
|
|
810
|
-
|
|
882
|
+
// #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
|
|
883
|
+
// keeps after `EnterWorktree` (the manifest lives in the worktree). See
|
|
884
|
+
// `resolveSessionRoot` in scripts/lib/platform.mjs.
|
|
885
|
+
const repoRootRaw = resolveSessionRoot(input.cwd);
|
|
811
886
|
let repoRoot;
|
|
812
887
|
try {
|
|
813
888
|
repoRoot = realpathSync(repoRootRaw);
|
|
@@ -833,7 +908,14 @@ async function main() {
|
|
|
833
908
|
// G3 — no wave scope → nothing defines "outside". #938: if a previous call
|
|
834
909
|
// RECORDED a scope state, the control file's disappearance is itself a
|
|
835
910
|
// control-file change and gets one visible (non-alarming) notice.
|
|
836
|
-
|
|
911
|
+
//
|
|
912
|
+
// #1504 point 6 — the first manifest that is not provably a peer's, so a
|
|
913
|
+
// peer's higher-precedence manifest no longer hides this session's own one;
|
|
914
|
+
// only when every candidate is foreign does the first come back, and G3b
|
|
915
|
+
// below keeps its stand-down and its rebind notice unchanged.
|
|
916
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
917
|
+
const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
|
|
918
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
837
919
|
if (!scopePath) {
|
|
838
920
|
if (prevScopeState && prevScopeState.hash !== 'absent') {
|
|
839
921
|
writeSnapshot(snapFile, {
|
|
@@ -857,7 +939,7 @@ async function main() {
|
|
|
857
939
|
} catch {
|
|
858
940
|
/* raw stays null (unreadable) or scope stays null (unparseable) */
|
|
859
941
|
}
|
|
860
|
-
if (raw === null) return; // file vanished between
|
|
942
|
+
if (raw === null) return; // file vanished between findOwnScopeFile and read
|
|
861
943
|
|
|
862
944
|
// The control-file identity of THIS call, computed BEFORE the G3b
|
|
863
945
|
// stand-down (W4/F7). Ordering is the whole fix: G3b used to `return` above
|
|
@@ -898,7 +980,6 @@ async function main() {
|
|
|
898
980
|
// CLI, Cursor today) the own-id set is empty, so this gate is permanently
|
|
899
981
|
// `'unknown'` = full pre-#1153 behaviour there.
|
|
900
982
|
{
|
|
901
|
-
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
902
983
|
const { verdict, manifestIds } = classifyManifestSession(scope ?? {}, ownIds);
|
|
903
984
|
if (verdict === 'foreign') {
|
|
904
985
|
// Observability only — one event per stand-down decision, awaited so the
|
|
@@ -35,8 +35,13 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
35
35
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
36
36
|
|
|
37
37
|
import { readStdin } from '../scripts/lib/io.mjs';
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
38
|
+
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
39
|
+
import { gateEnabled } from '../scripts/lib/hardening.mjs';
|
|
40
|
+
import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
41
|
+
import {
|
|
42
|
+
classifyManifestSession,
|
|
43
|
+
readProcessLocalSessionIds,
|
|
44
|
+
} from '../scripts/lib/session-identity/own-session.mjs';
|
|
40
45
|
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
41
46
|
|
|
42
47
|
// ---------------------------------------------------------------------------
|
|
@@ -184,7 +189,9 @@ async function main() {
|
|
|
184
189
|
const ext = path.extname(filePath).toLowerCase();
|
|
185
190
|
if (!TS_EXTS.has(ext)) process.exit(0);
|
|
186
191
|
|
|
187
|
-
|
|
192
|
+
// The SESSION root the scope guards use (#1492, #1504 point 4) — not the
|
|
193
|
+
// launch dir, which stays put while a session works in an entered worktree.
|
|
194
|
+
const projectRoot = resolveSessionRoot(input?.cwd);
|
|
188
195
|
|
|
189
196
|
// Compute relative path (REQ-03)
|
|
190
197
|
const absFilePath = path.isAbsolute(filePath)
|
|
@@ -192,8 +199,11 @@ async function main() {
|
|
|
192
199
|
: path.resolve(projectRoot, filePath);
|
|
193
200
|
const relPath = path.relative(projectRoot, absFilePath);
|
|
194
201
|
|
|
195
|
-
// G5: per-gate toggle — skip if post-edit-validate gate is disabled
|
|
196
|
-
|
|
202
|
+
// G5: per-gate toggle — skip if post-edit-validate gate is disabled. Only
|
|
203
|
+
// THIS session's manifest may switch it off: a peer's manifest in the same
|
|
204
|
+
// working copy is skipped, and a peer-only result means no toggle (#1504).
|
|
205
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
206
|
+
const { path: scopeFile } = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
|
|
197
207
|
if (scopeFile && !gateEnabled(scopeFile, 'post-edit-validate')) {
|
|
198
208
|
process.exit(0);
|
|
199
209
|
}
|
|
@@ -66,21 +66,22 @@
|
|
|
66
66
|
* hooks.json wiring is managed separately (W3-C4 scope).
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
|
-
import { execFileSync
|
|
70
|
-
import { readFileSync } from 'node:fs';
|
|
69
|
+
import { execFileSync } from 'node:child_process';
|
|
71
70
|
import { readFile } from 'node:fs/promises';
|
|
72
71
|
import path from 'node:path';
|
|
73
|
-
import { performance } from 'node:perf_hooks';
|
|
74
|
-
import { fileURLToPath } from 'node:url';
|
|
75
72
|
|
|
76
73
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
77
74
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
78
75
|
|
|
79
|
-
import { getProjectDir } from '../scripts/lib/platform.mjs';
|
|
76
|
+
import { getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
80
77
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
81
|
-
import {
|
|
78
|
+
import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
79
|
+
import {
|
|
80
|
+
classifyManifestSession,
|
|
81
|
+
readProcessLocalSessionIds,
|
|
82
|
+
} from '../scripts/lib/session-identity/own-session.mjs';
|
|
82
83
|
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
83
|
-
import {
|
|
84
|
+
import { maybeTriggerOrphanScan } from '../scripts/lib/orphan-reaper/trigger.mjs';
|
|
84
85
|
|
|
85
86
|
// ---------------------------------------------------------------------------
|
|
86
87
|
// Helpers
|
|
@@ -203,18 +204,23 @@ async function ownsSessionFile(input, sessionFile) {
|
|
|
203
204
|
* wave-scope.json absent"). The file is deleted mid-session at Quality phase
|
|
204
205
|
* transitions and final cleanup, so absence is an expected, non-error state.
|
|
205
206
|
*
|
|
206
|
-
* The manifest is located via `
|
|
207
|
+
* The manifest is located via `findOwnScopeFile()` (#1504), the same precedence
|
|
207
208
|
* every other consumer uses — `.pi` > `.cursor` > `.codex` > `.claude`. The
|
|
208
209
|
* previous hard-coded `.claude/` path made this hook's whole wave-lifecycle
|
|
209
210
|
* fallback structurally dead on Codex CLI, Cursor and pi: the manifest exists,
|
|
210
211
|
* it just is not under `.claude/`, so every batch read 0 and no
|
|
211
212
|
* wave-lifecycle event was ever emitted there.
|
|
212
213
|
*
|
|
213
|
-
*
|
|
214
|
+
* Only THIS session's manifest counts (#1504): `findOwnScopeFile` skips a
|
|
215
|
+
* peer's manifest in the same working copy, and a peer-only result is "no own
|
|
216
|
+
* wave" → 0, never the peer's number recorded as ours.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} projectDir the session root (`resolveSessionRoot`)
|
|
219
|
+
* @param {Set<string>} ownIds this process's own session ids
|
|
214
220
|
* @returns {Promise<number>}
|
|
215
221
|
*/
|
|
216
|
-
async function resolveWaveNumber(projectDir) {
|
|
217
|
-
const waveFile =
|
|
222
|
+
async function resolveWaveNumber(projectDir, ownIds) {
|
|
223
|
+
const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
|
|
218
224
|
if (waveFile === null) return 0;
|
|
219
225
|
try {
|
|
220
226
|
const raw = await readFile(waveFile, 'utf8');
|
|
@@ -308,145 +314,6 @@ function countFilesChangedSince(projectDir, sha) {
|
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
// orphan-reaper trigger (#1432 B4)
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
//
|
|
315
|
-
// DUPLICATED VERBATIM between the two trigger hooks —
|
|
316
|
-
// hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
|
|
317
|
-
// a named revisit trigger (BV-004): they are the only two trigger points the
|
|
318
|
-
// PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
|
|
319
|
-
// file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
|
|
320
|
-
// blocks: 138 identical lines — this note said "~40 lines of glue" until then,
|
|
321
|
-
// which is what made the duplicate look cheaper than it is. Extract it the
|
|
322
|
-
// moment a THIRD hook needs the trigger, or the moment the copies must differ.
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Filesystem path of the scan CLI, spawned as a PLAIN argv call.
|
|
326
|
-
*
|
|
327
|
-
* It used to be a `file://` URL handed to `node --input-type=module -e
|
|
328
|
-
* <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
|
|
329
|
-
* has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
|
|
330
|
-
* contract lives in that module instead of as source text duplicated here —
|
|
331
|
-
* and nothing this hook builds is a program any more: every variable part is
|
|
332
|
-
* an argv value, which cannot become code whatever the checkout path contains.
|
|
333
|
-
*
|
|
334
|
-
* `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
|
|
335
|
-
* percent-encoded path for any checkout directory containing a space.
|
|
336
|
-
*/
|
|
337
|
-
const ORPHAN_REAPER_SCRIPT = fileURLToPath(
|
|
338
|
-
new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Read the `reaper:` block from the project's Session Config host file.
|
|
343
|
-
* Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
|
|
344
|
-
* path must not import the full config orchestrator. A missing or unreadable
|
|
345
|
-
* file yields the parser defaults, i.e. DISABLED.
|
|
346
|
-
*
|
|
347
|
-
* @param {string} projectDir
|
|
348
|
-
* @returns {ReturnType<typeof _parseReaper>}
|
|
349
|
-
*/
|
|
350
|
-
function loadReaperConfig(projectDir) {
|
|
351
|
-
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
352
|
-
try {
|
|
353
|
-
return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
|
|
354
|
-
} catch {
|
|
355
|
-
// missing or unreadable — try the next candidate
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return _parseReaper('');
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
|
|
363
|
-
*
|
|
364
|
-
* The hook itself does exactly two pieces of I/O — one config read and one
|
|
365
|
-
* `stat` of the throttle marker — and then hands the work to a DETACHED,
|
|
366
|
-
* unref'd child. The scan is never run inline: one `ps` round-trip out of Node
|
|
367
|
-
* was measured at ~47 ms over 287 KB of output, which alone would blow the
|
|
368
|
-
* 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
|
|
369
|
-
*
|
|
370
|
-
* The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
|
|
371
|
-
* the throttle window — otherwise a broken spawn would be retried on every
|
|
372
|
-
* single tool batch.
|
|
373
|
-
*
|
|
374
|
-
* `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
|
|
375
|
-
* with `performance.now()` and reported as ONE stderr WARN line when exceeded.
|
|
376
|
-
* A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
|
|
377
|
-
* per-fire telemetry record is exactly the always-on signal
|
|
378
|
-
* `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
|
|
379
|
-
* measurement covers preparation only — the scan itself runs detached, which is
|
|
380
|
-
* the whole reason the budget can be held.
|
|
381
|
-
*
|
|
382
|
-
* Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
|
|
383
|
-
*
|
|
384
|
-
* @param {object} [opts]
|
|
385
|
-
* @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
|
|
386
|
-
* @param {number} [opts.now] Injected clock (ms).
|
|
387
|
-
* @param {Function} [opts.spawnFn] Injected `spawn` (tests).
|
|
388
|
-
* @param {Function} [opts.statFn] Injected `statSync` (tests).
|
|
389
|
-
* @param {Function} [opts.writeFn] Injected marker writer (tests).
|
|
390
|
-
* @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
|
|
391
|
-
* budget (tests); defaults to `performance.now`.
|
|
392
|
-
* @returns {Promise<{spawned: boolean, reason: string}>}
|
|
393
|
-
*/
|
|
394
|
-
export async function maybeTriggerOrphanScan({
|
|
395
|
-
projectDir,
|
|
396
|
-
now,
|
|
397
|
-
spawnFn = spawn,
|
|
398
|
-
statFn,
|
|
399
|
-
writeFn,
|
|
400
|
-
clockFn = () => performance.now(),
|
|
401
|
-
} = {}) {
|
|
402
|
-
const startedAt = clockFn();
|
|
403
|
-
/** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
|
|
404
|
-
const checkLatency = (budgetMs) => {
|
|
405
|
-
const elapsed = clockFn() - startedAt;
|
|
406
|
-
if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
|
|
407
|
-
process.stderr.write(
|
|
408
|
-
`orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
|
|
409
|
-
+ `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
try {
|
|
414
|
-
const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
|
|
415
|
-
const cfg = loadReaperConfig(root);
|
|
416
|
-
// Cheapest gate first: disabled means no stat, no spawn, no module load.
|
|
417
|
-
if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
|
|
418
|
-
|
|
419
|
-
const reaper = await import('../scripts/lib/orphan-reaper.mjs');
|
|
420
|
-
const markerPath = reaper.scanMarkerPath(root);
|
|
421
|
-
const nowMs = typeof now === 'number' ? now : Date.now();
|
|
422
|
-
|
|
423
|
-
if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
|
|
424
|
-
checkLatency(cfg['max-hook-latency-ms']);
|
|
425
|
-
return { spawned: false, reason: 'throttled' };
|
|
426
|
-
}
|
|
427
|
-
reaper.touchScanMarker(markerPath, { writeFn });
|
|
428
|
-
|
|
429
|
-
const child = spawnFn(
|
|
430
|
-
process.execPath,
|
|
431
|
-
[
|
|
432
|
-
ORPHAN_REAPER_SCRIPT,
|
|
433
|
-
'--repo-root', root,
|
|
434
|
-
'--mode', cfg.mode,
|
|
435
|
-
'--min-age-seconds', String(cfg['min-age-seconds']),
|
|
436
|
-
'--kill-grace-ms', String(cfg['kill-grace-ms']),
|
|
437
|
-
'--verify-wait-ms', String(cfg['verify-wait-ms']),
|
|
438
|
-
'--false-alarm-window', String(cfg['false-alarm-window']),
|
|
439
|
-
],
|
|
440
|
-
{ detached: true, stdio: 'ignore' },
|
|
441
|
-
);
|
|
442
|
-
if (child && typeof child.unref === 'function') child.unref();
|
|
443
|
-
checkLatency(cfg['max-hook-latency-ms']);
|
|
444
|
-
return { spawned: true, reason: 'spawned' };
|
|
445
|
-
} catch {
|
|
446
|
-
return { spawned: false, reason: 'error' };
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
317
|
// ---------------------------------------------------------------------------
|
|
451
318
|
// Main
|
|
452
319
|
// ---------------------------------------------------------------------------
|
|
@@ -594,7 +461,10 @@ async function main() {
|
|
|
594
461
|
// when an N+1 transition already closed wave N, SessionEnd sees
|
|
595
462
|
// last_wave_completed === last_wave and stays silent.
|
|
596
463
|
try {
|
|
597
|
-
const wave = await resolveWaveNumber(
|
|
464
|
+
const wave = await resolveWaveNumber(
|
|
465
|
+
resolveSessionRoot(input?.cwd),
|
|
466
|
+
new Set(readProcessLocalSessionIds({ hookInput: input })),
|
|
467
|
+
);
|
|
598
468
|
if (wave > 0) {
|
|
599
469
|
// Read last_wave from the just-written session file (after the
|
|
600
470
|
// last_batch RMW above, so we observe the latest persisted value).
|
|
@@ -713,7 +583,17 @@ async function main() {
|
|
|
713
583
|
// `process.exit(0)` in a `finally`: an un-awaited spawn would race the exit.
|
|
714
584
|
// The await is bounded by design — one config read, one stat, one detached
|
|
715
585
|
// spawn, all measured under `reaper.max-hook-latency-ms` (50 ms).
|
|
716
|
-
|
|
586
|
+
//
|
|
587
|
+
// The returned reason is deliberately NOT recorded here (#1489): this hook
|
|
588
|
+
// emits no record per batch, and adding one is the per-fire telemetry HR-101
|
|
589
|
+
// rejects. The throttle marker is one file per repo, so a defeated marker
|
|
590
|
+
// (`spawned-unthrottled`) shows on the next Stop/SubagentStop record, whose
|
|
591
|
+
// `reaper_trigger` key hooks/on-stop.mjs fills from the same trigger.
|
|
592
|
+
//
|
|
593
|
+
// Rooted at the SESSION root (#1492 point 2, #1504 point 4): the quality gate
|
|
594
|
+
// registers its children in the ledger of the tree it runs in, which for an
|
|
595
|
+
// entered worktree is the worktree — the launch dir holds none of them.
|
|
596
|
+
await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
|
|
717
597
|
}
|
|
718
598
|
|
|
719
599
|
// Entry guard (#1393): run only as the node script the harness execs — a bare
|
|
@@ -24,11 +24,17 @@
|
|
|
24
24
|
|
|
25
25
|
import { readStdin, emitAllow } from '../scripts/lib/io.mjs';
|
|
26
26
|
import crypto from 'node:crypto';
|
|
27
|
-
import { existsSync } from 'node:fs';
|
|
27
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
28
28
|
import { readFile } from 'node:fs/promises';
|
|
29
|
+
import os from 'node:os';
|
|
29
30
|
import path from 'node:path';
|
|
30
31
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
31
|
-
import {
|
|
32
|
+
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
33
|
+
import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
34
|
+
import {
|
|
35
|
+
classifyManifestSession,
|
|
36
|
+
readProcessLocalSessionIds,
|
|
37
|
+
} from '../scripts/lib/session-identity/own-session.mjs';
|
|
32
38
|
|
|
33
39
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
34
40
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
@@ -86,7 +92,8 @@ const KNOWN_FLAGS = Object.freeze([
|
|
|
86
92
|
*
|
|
87
93
|
* WHY THE HASH IS HERE (#1415, 2026-09-21): until today this hook wrote a
|
|
88
94
|
* merely flag-redacted `argv_truncated` (512 chars of command text) into
|
|
89
|
-
* `orchestrator.memory.propose_invoked` —
|
|
95
|
+
* `orchestrator.memory.propose_invoked` — an `events.jsonl` line (gitignored
|
|
96
|
+
* here via `.gitignore:57`; consumer repos may track it) and,
|
|
90
97
|
* with the webhook configured, a network payload. Redaction covered only the
|
|
91
98
|
* five `--insight/--subject/--evidence/--content/--reason` values and said so
|
|
92
99
|
* in its own caveat: `$VAR`, `$(cat secret)` and any value written elsewhere
|
|
@@ -123,6 +130,53 @@ function flagsPresent(command) {
|
|
|
123
130
|
);
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The working directory as a value that carries no absolute host path (#1439).
|
|
135
|
+
*
|
|
136
|
+
* Relative to `projectDir` (`.` when equal); a directory OUTSIDE the project is
|
|
137
|
+
* written as `sha256:<12 hex>` of its resolved path instead — still groupable,
|
|
138
|
+
* never the path itself. Until 2026-09-25 this was `process.cwd()` verbatim,
|
|
139
|
+
* i.e. `/Users/<name>/…` in an events.jsonl line (gitignored here via
|
|
140
|
+
* `.gitignore:57`; consumer repos may track it) and, with the webhook
|
|
141
|
+
* configured, in a network payload. No production reader consumes `cwd`.
|
|
142
|
+
*
|
|
143
|
+
* Both sides are realpath-resolved before comparing: on macOS the tmp dir is
|
|
144
|
+
* `/var/…` while `process.cwd()` reports `/private/var/…`, and a naive
|
|
145
|
+
* `path.relative` then calls the project dir itself `../../../private/var/…`.
|
|
146
|
+
* A path that cannot be resolved falls back to `path.resolve` — never throws.
|
|
147
|
+
*
|
|
148
|
+
* A project dir that is no project boundary — the filesystem root, the home
|
|
149
|
+
* dir, or an ancestor of it (`/Users`) — also gets the hash form: relative to
|
|
150
|
+
* `/` the cwd reads `Users/<name>/…`, i.e. the username in a webhook-bound
|
|
151
|
+
* event, the very leak the relative form exists to prevent. The ancestor test
|
|
152
|
+
* is path-segment-wise (`path.relative`), so `/Users/al` is NOT an ancestor of
|
|
153
|
+
* `/Users/alice` the way a string prefix would claim.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} cwd
|
|
156
|
+
* @param {string} projectDir
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function relativeCwd(cwd, projectDir) {
|
|
160
|
+
const real = (p) => {
|
|
161
|
+
try { return realpathSync(p); } catch { return path.resolve(p); }
|
|
162
|
+
};
|
|
163
|
+
// `..`-prefixed (above the base) or absolute (another Windows drive).
|
|
164
|
+
const escapes = (rel) =>
|
|
165
|
+
rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
166
|
+
const realCwd = real(cwd);
|
|
167
|
+
const realProject = real(projectDir);
|
|
168
|
+
const rel = path.relative(realProject, realCwd);
|
|
169
|
+
if (rel === '') return '.';
|
|
170
|
+
// Same sha256 recipe as `command_hash`, cut to 12 hex.
|
|
171
|
+
const hashed = `sha256:${hashCommand(realCwd).slice(0, 12)}`;
|
|
172
|
+
if (escapes(rel)) return hashed;
|
|
173
|
+
if (path.dirname(realProject) === realProject) return hashed; // filesystem root
|
|
174
|
+
let home = null;
|
|
175
|
+
try { home = real(os.homedir()); } catch { /* no home dir: root check above still holds */ }
|
|
176
|
+
if (home !== null && !escapes(path.relative(realProject, home))) return hashed;
|
|
177
|
+
return rel;
|
|
178
|
+
}
|
|
179
|
+
|
|
126
180
|
/**
|
|
127
181
|
* Resolve the session_id from the hook stdin payload, with fallback to the
|
|
128
182
|
* persisted file written by on-session-start.mjs. Returns null when neither
|
|
@@ -152,14 +206,16 @@ async function resolveSessionId(input, projectDir) {
|
|
|
152
206
|
}
|
|
153
207
|
|
|
154
208
|
/**
|
|
155
|
-
* Resolve the current wave number from
|
|
156
|
-
* Returns 0 when the file is absent or unparseable
|
|
209
|
+
* Resolve the current wave number from THIS session's wave-scope.json.
|
|
210
|
+
* Returns 0 when the file is absent or unparseable — and when the only
|
|
211
|
+
* manifest is a peer's (#1504): its number is not this session's wave.
|
|
157
212
|
*
|
|
158
|
-
* @param {string} projectDir
|
|
213
|
+
* @param {string} projectDir the session root (`resolveSessionRoot`)
|
|
214
|
+
* @param {Set<string>} ownIds this process's own session ids
|
|
159
215
|
* @returns {Promise<number>}
|
|
160
216
|
*/
|
|
161
|
-
async function resolveWave(projectDir) {
|
|
162
|
-
const waveFile =
|
|
217
|
+
async function resolveWave(projectDir, ownIds) {
|
|
218
|
+
const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
|
|
163
219
|
if (!waveFile || !existsSync(waveFile)) return 0;
|
|
164
220
|
try {
|
|
165
221
|
const raw = await readFile(waveFile, 'utf8');
|
|
@@ -198,7 +254,12 @@ async function main() {
|
|
|
198
254
|
const sessionId = await resolveSessionId(input, projectDir);
|
|
199
255
|
|
|
200
256
|
// G5 — resolve wave
|
|
201
|
-
|
|
257
|
+
// Read at the SESSION root the scope guards use (#1504 point 4), not at the
|
|
258
|
+
// launch dir above, which stays put while the session works in a worktree.
|
|
259
|
+
const wave = await resolveWave(
|
|
260
|
+
resolveSessionRoot(input.cwd),
|
|
261
|
+
new Set(readProcessLocalSessionIds({ hookInput: input })),
|
|
262
|
+
);
|
|
202
263
|
|
|
203
264
|
// G6 — derive the non-reversible argv summary (#1415). Nothing downstream
|
|
204
265
|
// ever sees the command text: hash for grouping, known flag NAMES for
|
|
@@ -219,7 +280,7 @@ async function main() {
|
|
|
219
280
|
command_hash: commandHash,
|
|
220
281
|
flags_present: flags,
|
|
221
282
|
argv_length: command.length,
|
|
222
|
-
cwd: process.cwd(),
|
|
283
|
+
cwd: relativeCwd(process.cwd(), projectDir),
|
|
223
284
|
exit_code: null,
|
|
224
285
|
});
|
|
225
286
|
} catch { /* telemetry never blocks the hook (#1183) */ }
|