session-orchestrator 5.4.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +115 -3
- package/README.md +30 -15
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +19 -15
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- 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.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- 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 +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -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-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- 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 +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +60 -6
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- 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 +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- 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 +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -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
|
*
|
|
@@ -359,14 +408,11 @@ export { PEER_RECORD_PREFIX };
|
|
|
359
408
|
* @param {unknown} wave the manifest's `wave` field
|
|
360
409
|
* @returns {Array<{ id: string, files: string[] }>}
|
|
361
410
|
*/
|
|
362
|
-
export function readPeerScopeRecords(stateDir, wave) {
|
|
363
|
-
if (typeof
|
|
364
|
-
if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return [];
|
|
411
|
+
export function readPeerScopeRecords(stateDir, wave, raw = readPeerSidecarRaw(stateDir, wave)) {
|
|
412
|
+
if (typeof raw !== 'string') return [];
|
|
365
413
|
let parsed;
|
|
366
414
|
try {
|
|
367
|
-
parsed = JSON.parse(
|
|
368
|
-
readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8'),
|
|
369
|
-
);
|
|
415
|
+
parsed = JSON.parse(raw);
|
|
370
416
|
} catch {
|
|
371
417
|
return [];
|
|
372
418
|
}
|
|
@@ -380,18 +426,115 @@ export function readPeerScopeRecords(stateDir, wave) {
|
|
|
380
426
|
.map((r) => ({ id: r.id, files: r.files.filter((f) => typeof f === 'string') }));
|
|
381
427
|
}
|
|
382
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The raw bytes of a wave's aggregate scope sidecar, or null (absent,
|
|
431
|
+
* unreadable, or no usable state dir / wave). Read ONCE per call so the bytes
|
|
432
|
+
* that are hashed ({@link bindPeerSidecar}) are the bytes that are parsed.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} stateDir
|
|
435
|
+
* @param {unknown} wave
|
|
436
|
+
* @returns {string|null}
|
|
437
|
+
*/
|
|
438
|
+
export function readPeerSidecarRaw(stateDir, wave) {
|
|
439
|
+
if (typeof stateDir !== 'string' || stateDir === '') return null;
|
|
440
|
+
if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return null;
|
|
441
|
+
try {
|
|
442
|
+
return readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8');
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Bind the aggregate sidecar's content to the manifest it was written for
|
|
450
|
+
* (#1504 point 5). The sidecar carried no integrity anchor: a `peer-session-*`
|
|
451
|
+
* record appended AFTER dispatch downgraded matching violations to a peer-write
|
|
452
|
+
* notice, because the control hash covers only `wave-scope.json`.
|
|
453
|
+
*
|
|
454
|
+
* The anchor is the trusted snapshot's `scopeState` — the one record this hook
|
|
455
|
+
* already treats as control state. The coordinator's order is materialize →
|
|
456
|
+
* assert-disjoint → union → write manifest, so the sidecar is final by the time
|
|
457
|
+
* the manifest is; the hash is therefore (re)bound whenever the MANIFEST hash
|
|
458
|
+
* changes (or no binding exists yet), and must hold while the manifest stands.
|
|
459
|
+
* No coordinator step and no new field: a materializer-written digest would sit
|
|
460
|
+
* in a file the same actor can rewrite, which binds nothing.
|
|
461
|
+
*
|
|
462
|
+
* manifest changed / no prior binding → bind the current hash, honour records
|
|
463
|
+
* same manifest, same sidecar hash → honour records
|
|
464
|
+
* same manifest, sidecar now absent → nothing to honour; keep the binding so
|
|
465
|
+
* a re-created sidecar still mismatches
|
|
466
|
+
* same manifest, DIFFERENT sidecar → `tampered`: honour NO peer record
|
|
467
|
+
* (fail toward reporting) and keep the
|
|
468
|
+
* ORIGINAL binding until the manifest is
|
|
469
|
+
* rewritten; the notice fires ONCE per
|
|
470
|
+
* mismatching sidecar hash (`notify`)
|
|
471
|
+
*
|
|
472
|
+
* Once, not per call: a legitimate mid-wave Peer-Scope-Union rewrites the sidecar
|
|
473
|
+
* while `--union` (which excludes peer records) leaves the manifest bytes
|
|
474
|
+
* unchanged, so `tampered` holds for the rest of the wave. Repeating the ⚠ on
|
|
475
|
+
* every Bash call would be an always-on signal (HR-101). The marker
|
|
476
|
+
* `sidecarNotified` is persisted in the trusted snapshot by the caller only on
|
|
477
|
+
* the path that emits; a further change of the sidecar (a new hash) notifies again.
|
|
478
|
+
*
|
|
479
|
+
* A snapshot written before this field existed has no `sidecarHash` and binds
|
|
480
|
+
* silently on its first call — the same one-time adoption the control hash had.
|
|
481
|
+
*
|
|
482
|
+
* NAMED CEILING: a sidecar changed BEFORE this hook first observes the manifest
|
|
483
|
+
* (no trusted snapshot yet) is bound as found; so is one rewritten together with
|
|
484
|
+
* the manifest (that path already raises the control-file notice). A forged
|
|
485
|
+
* COMPLETE snapshot record is believed — `trustedScopeState`'s residual.
|
|
486
|
+
*
|
|
487
|
+
* @param {{ hash: string, sidecarHash?: unknown }|null} prevScopeState trusted prior state
|
|
488
|
+
* @param {string} manifestHash this call's manifest hash
|
|
489
|
+
* @param {string} sidecarHash this call's sidecar hash, or 'absent'
|
|
490
|
+
* @returns {{ sidecarHash: string, tampered: boolean, notify: boolean, sidecarNotified?: string }}
|
|
491
|
+
* the hash to persist, the verdict, whether to emit the mismatch notice, and
|
|
492
|
+
* (when tampered) the marker to persist once it has been emitted
|
|
493
|
+
*/
|
|
494
|
+
export function bindPeerSidecar(prevScopeState, manifestHash, sidecarHash) {
|
|
495
|
+
const bound = prevScopeState?.sidecarHash;
|
|
496
|
+
if (!prevScopeState || prevScopeState.hash !== manifestHash || typeof bound !== 'string') {
|
|
497
|
+
return { sidecarHash, tampered: false, notify: false };
|
|
498
|
+
}
|
|
499
|
+
if (bound === sidecarHash || sidecarHash === 'absent') {
|
|
500
|
+
return { sidecarHash: bound, tampered: false, notify: false };
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
sidecarHash: bound,
|
|
504
|
+
tampered: true,
|
|
505
|
+
notify: prevScopeState.sidecarNotified !== sidecarHash,
|
|
506
|
+
sidecarNotified: sidecarHash,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Render the sidecar-mismatch notice (#1504 point 5) — an alarm (⚠), because the
|
|
512
|
+
* consequence is that agreed peer writes now report as violations.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} relSidecarPath
|
|
515
|
+
* @returns {string}
|
|
516
|
+
*/
|
|
517
|
+
export function formatSidecarMismatchNotice(relSidecarPath) {
|
|
518
|
+
return `bash-write-verify: ${relSidecarPath} changed since wave-scope.json was written — `
|
|
519
|
+
+ 'its peer-session-* records are NOT honoured (writes under them report as violations) '
|
|
520
|
+
+ 'until the manifest is rewritten (#1504 point 5).';
|
|
521
|
+
}
|
|
522
|
+
|
|
383
523
|
/**
|
|
384
524
|
* Which peer record — if any — declared this path?
|
|
385
525
|
*
|
|
386
526
|
* Same matcher as `isInScope`, so a peer's declaration is read exactly the way
|
|
387
|
-
* the wave's own `allowedPaths` are
|
|
527
|
+
* the wave's own `allowedPaths` are — and the same outside-root guard as
|
|
528
|
+
* `computeReport`: a `../…` path is outside the project root, which no peer
|
|
529
|
+
* record can grant any more than `allowedPaths` can (a peer glob over every
|
|
530
|
+
* `.md` file would otherwise file `<mono>/NOTES.md` as an agreed peer write).
|
|
388
531
|
*
|
|
389
532
|
* @param {string} relPath
|
|
390
533
|
* @param {Array<{ id: string, files: string[] }>} peerRecords
|
|
391
534
|
* @returns {string|null} the peer record id, or null
|
|
392
535
|
*/
|
|
393
536
|
export function peerRecordFor(relPath, peerRecords) {
|
|
394
|
-
if (!Array.isArray(peerRecords)) return null;
|
|
537
|
+
if (!Array.isArray(peerRecords) || isOutsideSessionRoot(relPath)) return null;
|
|
395
538
|
for (const record of peerRecords) {
|
|
396
539
|
if (record.files.some((p) => pathMatchesPattern(relPath, p))) return record.id;
|
|
397
540
|
}
|
|
@@ -466,14 +609,22 @@ export function snapshotPathFor(repoRoot) {
|
|
|
466
609
|
export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
|
|
467
610
|
const outOfScope = dirtyPaths
|
|
468
611
|
.filter((p) => !isIgnoredPath(p))
|
|
469
|
-
|
|
612
|
+
// A `../…` path (outside the project root, see rebaseToSessionRoot) is never
|
|
613
|
+
// in scope — not even under a broad glob that would happen to match it.
|
|
614
|
+
.filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths))
|
|
470
615
|
// The wave-scope.json control file is out-of-scope by construction (it is
|
|
471
616
|
// never under allowedPaths) but its CHANGES are reported via the content-hash
|
|
472
617
|
// control-notice path, not here. Excluding it keeps the mtime re-baseline
|
|
473
618
|
// filter from reporting the scope file against ITSELF — its mtime always
|
|
474
619
|
// equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
|
|
475
620
|
// every first run (#938 MED-3 follow-through).
|
|
476
|
-
.filter((p) => p !== scopeRelPath)
|
|
621
|
+
.filter((p) => p !== scopeRelPath)
|
|
622
|
+
// Its sibling control state, `<state-dir>/filescopes/` (the per-agent scope
|
|
623
|
+
// declarations `materialize-wave-scope.mjs` writes once per wave), is no
|
|
624
|
+
// wave territory either — excluded the same way, not via
|
|
625
|
+
// IGNORED_PATH_PATTERNS, so the exemption follows THIS manifest's state dir
|
|
626
|
+
// instead of granting all four harness dirs (#1027 Pkt 5).
|
|
627
|
+
.filter((p) => !isScopeDeclarationPath(p, scopeRelPath));
|
|
477
628
|
|
|
478
629
|
const rebaselined = !snapshot || snapshot.signature !== signature;
|
|
479
630
|
const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
|
|
@@ -515,7 +666,9 @@ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, s
|
|
|
515
666
|
* @returns {string}
|
|
516
667
|
*/
|
|
517
668
|
export function formatMessage(report, allowedCount) {
|
|
518
|
-
const shown = report
|
|
669
|
+
const shown = report
|
|
670
|
+
.slice(0, MAX_REPORTED_PATHS)
|
|
671
|
+
.map((p) => (isOutsideSessionRoot(p) ? `${p} (outside project root)` : p));
|
|
519
672
|
const more = report.length - shown.length;
|
|
520
673
|
const tail = more > 0 ? ` (+${more} more)` : '';
|
|
521
674
|
const msg =
|
|
@@ -608,12 +761,13 @@ export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null
|
|
|
608
761
|
// ---------------------------------------------------------------------------
|
|
609
762
|
|
|
610
763
|
/**
|
|
611
|
-
* Collect the working-tree dirty set
|
|
764
|
+
* Collect the working-tree dirty set, rebased onto the session root
|
|
765
|
+
* ({@link rebaseToSessionRoot}). Returns null when git is unavailable, the
|
|
612
766
|
* directory is not a repo, or the call times out — all of which mean "no signal
|
|
613
767
|
* to report", never a warning.
|
|
614
768
|
*
|
|
615
|
-
* @param {string} repoRoot
|
|
616
|
-
* @returns {string[]|null}
|
|
769
|
+
* @param {string} repoRoot the session root
|
|
770
|
+
* @returns {string[]|null} session-root-relative paths (`../…` = outside it)
|
|
617
771
|
*/
|
|
618
772
|
function readDirtyPaths(repoRoot) {
|
|
619
773
|
try {
|
|
@@ -630,7 +784,16 @@ function readDirtyPaths(repoRoot) {
|
|
|
630
784
|
['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
|
|
631
785
|
{ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
632
786
|
);
|
|
633
|
-
|
|
787
|
+
// Porcelain paths are toplevel-relative; everything they are judged against
|
|
788
|
+
// is session-root-relative. A `.git` AT the session root makes it the
|
|
789
|
+
// toplevel (prefix ''), so the extra spawn is paid only by a session
|
|
790
|
+
// launched from a repo subdirectory.
|
|
791
|
+
const prefix = existsSync(path.join(repoRoot, '.git'))
|
|
792
|
+
? ''
|
|
793
|
+
: execFileSync('git', ['rev-parse', '--show-prefix'], {
|
|
794
|
+
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
|
|
795
|
+
}).trim();
|
|
796
|
+
return rebaseToSessionRoot(parsePorcelainZ(raw), prefix);
|
|
634
797
|
} catch {
|
|
635
798
|
return null;
|
|
636
799
|
}
|
|
@@ -807,7 +970,10 @@ async function main() {
|
|
|
807
970
|
// G2 — only Bash calls carry the bypass risk this hook watches.
|
|
808
971
|
if (input.tool_name !== 'Bash') return;
|
|
809
972
|
|
|
810
|
-
|
|
973
|
+
// #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
|
|
974
|
+
// keeps after `EnterWorktree` (the manifest lives in the worktree). See
|
|
975
|
+
// `resolveSessionRoot` in scripts/lib/platform.mjs.
|
|
976
|
+
const repoRootRaw = resolveSessionRoot(input.cwd);
|
|
811
977
|
let repoRoot;
|
|
812
978
|
try {
|
|
813
979
|
repoRoot = realpathSync(repoRootRaw);
|
|
@@ -833,7 +999,14 @@ async function main() {
|
|
|
833
999
|
// G3 — no wave scope → nothing defines "outside". #938: if a previous call
|
|
834
1000
|
// RECORDED a scope state, the control file's disappearance is itself a
|
|
835
1001
|
// control-file change and gets one visible (non-alarming) notice.
|
|
836
|
-
|
|
1002
|
+
//
|
|
1003
|
+
// #1504 point 6 — the first manifest that is not provably a peer's, so a
|
|
1004
|
+
// peer's higher-precedence manifest no longer hides this session's own one;
|
|
1005
|
+
// only when every candidate is foreign does the first come back, and G3b
|
|
1006
|
+
// below keeps its stand-down and its rebind notice unchanged.
|
|
1007
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
1008
|
+
const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
|
|
1009
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
837
1010
|
if (!scopePath) {
|
|
838
1011
|
if (prevScopeState && prevScopeState.hash !== 'absent') {
|
|
839
1012
|
writeSnapshot(snapFile, {
|
|
@@ -857,7 +1030,7 @@ async function main() {
|
|
|
857
1030
|
} catch {
|
|
858
1031
|
/* raw stays null (unreadable) or scope stays null (unparseable) */
|
|
859
1032
|
}
|
|
860
|
-
if (raw === null) return; // file vanished between
|
|
1033
|
+
if (raw === null) return; // file vanished between findOwnScopeFile and read
|
|
861
1034
|
|
|
862
1035
|
// The control-file identity of THIS call, computed BEFORE the G3b
|
|
863
1036
|
// stand-down (W4/F7). Ordering is the whole fix: G3b used to `return` above
|
|
@@ -878,6 +1051,16 @@ async function main() {
|
|
|
878
1051
|
: 'unparseable',
|
|
879
1052
|
gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
|
|
880
1053
|
};
|
|
1054
|
+
// #1504 point 5 — bind the aggregate sidecar to this manifest (see
|
|
1055
|
+
// `bindPeerSidecar`). Part of `currentScopeState` so EVERY snapshot write
|
|
1056
|
+
// below, stand-down paths included, carries the binding forward.
|
|
1057
|
+
const sidecarRaw = readPeerSidecarRaw(path.dirname(scopePath), scope?.wave);
|
|
1058
|
+
const sidecarBinding = bindPeerSidecar(
|
|
1059
|
+
prevScopeState,
|
|
1060
|
+
currentScopeState.hash,
|
|
1061
|
+
sidecarRaw === null ? 'absent' : createHash('sha1').update(sidecarRaw).digest('hex').slice(0, 16),
|
|
1062
|
+
);
|
|
1063
|
+
currentScopeState.sidecarHash = sidecarBinding.sidecarHash;
|
|
881
1064
|
|
|
882
1065
|
// G3b (#1153 P1) — is this manifest even MINE?
|
|
883
1066
|
//
|
|
@@ -898,7 +1081,6 @@ async function main() {
|
|
|
898
1081
|
// CLI, Cursor today) the own-id set is empty, so this gate is permanently
|
|
899
1082
|
// `'unknown'` = full pre-#1153 behaviour there.
|
|
900
1083
|
{
|
|
901
|
-
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
902
1084
|
const { verdict, manifestIds } = classifyManifestSession(scope ?? {}, ownIds);
|
|
903
1085
|
if (verdict === 'foreign') {
|
|
904
1086
|
// Observability only — one event per stand-down decision, awaited so the
|
|
@@ -1072,6 +1254,9 @@ async function main() {
|
|
|
1072
1254
|
},
|
|
1073
1255
|
});
|
|
1074
1256
|
|
|
1257
|
+
// The mismatch notice below is emitted on this path only, so its once-marker
|
|
1258
|
+
// is persisted here and nowhere else (see `bindPeerSidecar`).
|
|
1259
|
+
if (sidecarBinding.tampered) currentScopeState.sidecarNotified = sidecarBinding.sidecarNotified;
|
|
1075
1260
|
writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
|
|
1076
1261
|
|
|
1077
1262
|
const messages = [];
|
|
@@ -1091,7 +1276,17 @@ async function main() {
|
|
|
1091
1276
|
// reachable. Such a write is agreed, not a bypass. Split it out of the violation report and
|
|
1092
1277
|
// name it, so the peer's file is countable without being an alarm. Sidecar
|
|
1093
1278
|
// absent ⇒ `peerRecords` is empty ⇒ every path stays a violation, unchanged.
|
|
1094
|
-
|
|
1279
|
+
// #1504 point 5 — a sidecar changed under a standing manifest is untrusted:
|
|
1280
|
+
// no peer record is honoured, and the mismatch is named.
|
|
1281
|
+
const peerRecords = sidecarBinding.tampered
|
|
1282
|
+
? []
|
|
1283
|
+
: readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
|
|
1284
|
+
if (sidecarBinding.notify) {
|
|
1285
|
+
messages.push(formatSidecarMismatchNotice(
|
|
1286
|
+
path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
|
|
1287
|
+
));
|
|
1288
|
+
warn = true;
|
|
1289
|
+
}
|
|
1095
1290
|
const violations = [];
|
|
1096
1291
|
for (const relPath of report) {
|
|
1097
1292
|
const peerId = peerRecordFor(relPath, peerRecords);
|
|
@@ -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
|
}
|