session-orchestrator 5.4.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/.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 +95 -3
- package/README.md +11 -11
- 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 +15 -13
- 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 +70 -170
- 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 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- 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 +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-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 +21 -2
- 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/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 +13 -5
- 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
package/scripts/lib/platform.mjs
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* - plain constants (no filesystem work): SO_SHARED_DIR, SO_OS, SO_IS_WINDOWS,
|
|
9
9
|
* SO_IS_WSL, SO_PATH_SEP
|
|
10
10
|
* - pure resolvers: detectPlatform, resolvePluginRoot, resolveProjectDir,
|
|
11
|
-
* resolveStateDir, resolveConfigFile
|
|
11
|
+
* resolveSessionRoot, resolveStateDir, resolveConfigFile
|
|
12
12
|
*
|
|
13
13
|
* NOTHING in this module touches the filesystem at import time.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
|
|
@@ -249,6 +249,210 @@ export function resolveProjectDir(platform) {
|
|
|
249
249
|
return cwd;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// resolveSessionRoot (#1492)
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Basename of a worktree the HARNESS creates for an `isolation: "worktree"`
|
|
258
|
+
* subagent: `agent-` + the agent id. Measured 2026-10-02 over this host's
|
|
259
|
+
* transcripts: 13481 `.claude/worktrees/agent-<hex>` hits, every agent id of the
|
|
260
|
+
* form `a` + 16 hex (`agent-a00828908cdd83bd8`). A worktree a SESSION enters
|
|
261
|
+
* (`EnterWorktree`, `claude --worktree <name>`) lives in the same directory under
|
|
262
|
+
* a chosen name (`landing-produktschau`, `s71-politur`), so the name is the only
|
|
263
|
+
* discriminator the path offers.
|
|
264
|
+
*/
|
|
265
|
+
const HARNESS_AGENT_WORKTREE_RE = /^agent-[0-9a-f]{8,}$/;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The first non-blank launch-dir env var, in `resolveProjectDir()` order, or `''`.
|
|
269
|
+
* Trimmed: a whitespace-only value is truthy and would otherwise win
|
|
270
|
+
* (`development.md` § Error Handling, env-var fallback whitespace trap).
|
|
271
|
+
*
|
|
272
|
+
* The ONE definition of "the launch dir the harness states": the clamp in
|
|
273
|
+
* {@link resolveSessionRoot} and the relocation trace in `hooks/enforce-scope.mjs`
|
|
274
|
+
* must read the same chain, so a second copy of it is a second definition.
|
|
275
|
+
*
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
export function launchDirFromEnv() {
|
|
279
|
+
for (const name of ['CLAUDE_PROJECT_DIR', 'CODEX_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'PI_PROJECT_DIR']) {
|
|
280
|
+
const value = (process.env[name] || '').trim();
|
|
281
|
+
if (value !== '') return value;
|
|
282
|
+
}
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The nearest directory at or above `cwd` holding a `.git` entry — a directory,
|
|
288
|
+
* or the FILE a linked worktree carries — or `''` when none does. Git's own repo
|
|
289
|
+
* discovery walks up looking for exactly this entry, so where git answers, both
|
|
290
|
+
* agree (measured 2026-10-02: same answer, 0.017 ms here vs 4.57 ms for a
|
|
291
|
+
* `git rev-parse --show-toplevel` spawn). Existence only — the entry is not
|
|
292
|
+
* validated, which git would do.
|
|
293
|
+
*
|
|
294
|
+
* Not `walkUpFor(cwd, '.git', 'any')`: its home-directory boundary exists for
|
|
295
|
+
* PLATFORM markers (#1139); a repo whose root is `$HOME` is still a repo root.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} cwd
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
export function dotGitAncestor(cwd) {
|
|
301
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
302
|
+
if (existsSync(path.join(dir, '.git'))) return dir;
|
|
303
|
+
if (path.dirname(dir) === dir) return '';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `<P>` when `root` is a harness subagent worktree `<P>/.claude/worktrees/agent-<hex>`
|
|
309
|
+
* whose parent `<P>` is itself a working copy; otherwise `root` unchanged.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} root
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
function _liftHarnessAgentWorktree(root) {
|
|
315
|
+
if (!HARNESS_AGENT_WORKTREE_RE.test(path.basename(root))) return root;
|
|
316
|
+
const worktreesDir = path.dirname(root);
|
|
317
|
+
if (path.basename(worktreesDir) !== 'worktrees') return root;
|
|
318
|
+
const stateDir = path.dirname(worktreesDir);
|
|
319
|
+
if (path.basename(stateDir) !== '.claude') return root;
|
|
320
|
+
const parent = path.dirname(stateDir);
|
|
321
|
+
return existsSync(path.join(parent, '.git')) ? parent : root;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The NATIVE realpath, or the resolved spelling when the path does not exist.
|
|
326
|
+
*
|
|
327
|
+
* `realpathSync.native`, not the JS `realpathSync`: on a case-insensitive
|
|
328
|
+
* volume (APFS default) the JS form resolves symlinks but keeps the CALLER's
|
|
329
|
+
* letter case, so `/users/me/repo` stayed `/users/…` beside the git-found
|
|
330
|
+
* `/Users/…`, `path.relative` answered `../../users/…` and the clamp was
|
|
331
|
+
* skipped (#1504 point 8, measured 2026-10-03 on a home dir typed lowercase:
|
|
332
|
+
* JS `/users/<me>`, native `/Users/<me>`). Every comparison in the clamp chain
|
|
333
|
+
* goes through this one function — mixing the two forms re-opens the split.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} p
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
function _canonical(p) {
|
|
339
|
+
try { return realpathSync.native(p); } catch { return path.resolve(p); }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Is `child` a STRICT descendant of `parent` (never `parent` itself)? Both sides
|
|
344
|
+
* canonical, so macOS `/tmp` vs `/private/tmp` cannot split one directory in two.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} child
|
|
347
|
+
* @param {string} parent
|
|
348
|
+
* @returns {boolean}
|
|
349
|
+
*/
|
|
350
|
+
function _isStrictlyInside(child, parent) {
|
|
351
|
+
const rel = path.relative(_canonical(parent), _canonical(child));
|
|
352
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The SESSION ROOT (#1492) — the working copy whose control files belong to the
|
|
357
|
+
* session a hook fires in: `wave-scope.json`, the `.orchestrator/` ledgers, the
|
|
358
|
+
* event records. One resolver for every hook that reads them (enforce-scope,
|
|
359
|
+
* enforce-commands, post-bash-write-verify, pre-task-scope-disjoint).
|
|
360
|
+
*
|
|
361
|
+
* NOT the launch dir whenever the session works elsewhere: `$CLAUDE_PROJECT_DIR`
|
|
362
|
+
* stays on the dir the session was LAUNCHED in after it enters a worktree
|
|
363
|
+
* (docs/en/hooks § "Worktrees are different"), while the coordinator writes
|
|
364
|
+
* `wave-scope.json` into that worktree. Read from the launch dir, the manifest
|
|
365
|
+
* was absent and every scope gate allowed everything for the whole session,
|
|
366
|
+
* silently (#1492, measured in session main-2026-10-02-session-21); preferred
|
|
367
|
+
* for the dispatch ledger it also gave every worktree session of one launch dir
|
|
368
|
+
* ONE shared ledger and the wave key `w?` (measured on be6a2e3e).
|
|
369
|
+
*
|
|
370
|
+
* NOT the payload `cwd` itself: it follows the session's `cd` (docs/en/hooks
|
|
371
|
+
* § "cwd follows Claude"), and a `cd sub` must not move the session's state into
|
|
372
|
+
* `<root>/sub` — a second, empty ledger whose collisions were allowed (#1489 Pkt 6).
|
|
373
|
+
*
|
|
374
|
+
* NEVER ABOVE the launch dir: a session launched in a repo SUBDIRECTORY (a
|
|
375
|
+
* monorepo package, a project under a git-tracked `$HOME`) keeps its manifest
|
|
376
|
+
* there. The repo root above it holds none, so resolving to it switched every
|
|
377
|
+
* scope gate off (review HIGH-1 on 4fdbc469, measured DENY → ALLOW).
|
|
378
|
+
*
|
|
379
|
+
* Precedence:
|
|
380
|
+
* 1. no payload `cwd` → `resolveProjectDir()`, the pre-#1492 answer unchanged;
|
|
381
|
+
* 2. the repo root of `cwd` — `toplevel` when the caller already holds
|
|
382
|
+
* `git rev-parse --show-toplevel` of it, else the nearest `.git` ancestor
|
|
383
|
+
* (`dotGitAncestor`), which answers the same without a spawn, so neither a
|
|
384
|
+
* timed-out git (review MED on 63f35e8c) nor a hot-path budget can send a
|
|
385
|
+
* worktree session back to the launch dir — lifted to `<P>` when it is a
|
|
386
|
+
* harness subagent worktree `<P>/.claude/worktrees/agent-<hex>` (below),
|
|
387
|
+
* then replaced by the launch dir from env (`launchDirFromEnv`) when it
|
|
388
|
+
* lies strictly ABOVE it;
|
|
389
|
+
* 3. the launch dir from env (`CLAUDE_PROJECT_DIR` → `CODEX_PROJECT_DIR` →
|
|
390
|
+
* `CURSOR_PROJECT_DIR` → `PI_PROJECT_DIR`), only when `cwd` is in no repo;
|
|
391
|
+
* 4. `cwd`.
|
|
392
|
+
*
|
|
393
|
+
* The clamp reads the SAME launch-dir chain as rung 3, not `$CLAUDE_PROJECT_DIR`
|
|
394
|
+
* alone. The Cursor and Pi bridges delete `CLAUDE_PROJECT_DIR` and set their
|
|
395
|
+
* own `*_PROJECT_DIR` to the payload `cwd` on every call (`cursor-hook-bridge.mjs`,
|
|
396
|
+
* `pi-hook-bridge.mjs`); a clamp on `$CLAUDE_PROJECT_DIR` alone therefore never
|
|
397
|
+
* fired there, and a Cursor/Pi workspace in a repo SUBDIRECTORY climbed to the
|
|
398
|
+
* repo root, found no manifest and allowed every write (review HIGH-1 on
|
|
399
|
+
* b57e572c, measured end-to-end through `cursor-hook-bridge.mjs`: DENY at
|
|
400
|
+
* ff3e43b5 → ALLOW). For a Claude session nothing changes — `$CLAUDE_PROJECT_DIR`
|
|
401
|
+
* is first in the chain. For a bridge session the answer is the payload `cwd`
|
|
402
|
+
* whenever it sits in a repo, which is exactly what the bridges resolved before
|
|
403
|
+
* #1492 (`resolveProjectDir()` → their `*_PROJECT_DIR` → the payload `cwd`).
|
|
404
|
+
* The clamp does not ask whether `cwd` lies inside the launch dir: an
|
|
405
|
+
* `isolation: "worktree"` agent's `cwd` never does, and it must clamp too (see
|
|
406
|
+
* THE LIFT).
|
|
407
|
+
*
|
|
408
|
+
* THE LIFT. An `isolation: "worktree"` subagent's hook payload carries the
|
|
409
|
+
* worktree the harness made for it, `<P>/.claude/worktrees/agent-<hex>`. That
|
|
410
|
+
* worktree is a repo root of its own and holds no manifest, so rung 2 alone
|
|
411
|
+
* would disarm scope enforcement for every such agent. The harness puts it under
|
|
412
|
+
* the LAUNCH checkout, so `<P>` is what `$CLAUDE_PROJECT_DIR` resolved to before
|
|
413
|
+
* #1492 — the lift keeps exactly that, and the clamp keeps it for a launch in a
|
|
414
|
+
* subdirectory of `<P>`. It is keyed on the PATH SHAPE, never on which candidate
|
|
415
|
+
* holds a manifest: a root picked because a control file is readable there
|
|
416
|
+
* would let any directory that lacks one become the deciding root.
|
|
417
|
+
*
|
|
418
|
+
* Ceilings (BV-004):
|
|
419
|
+
* - An agent of a session that ENTERED a worktree is still unenforced, as before
|
|
420
|
+
* #1492: its worktree sits under the launch checkout (measured: session
|
|
421
|
+
* 0d5e4fc3, 2026-09-25 — cwd `<main>/.claude/worktrees/landing-produktschau`,
|
|
422
|
+
* agent worktreePath `<main>/.claude/worktrees/agent-acd5bfd0aa590e2e8`), so
|
|
423
|
+
* the lift lands on the launch root, where that session has no manifest.
|
|
424
|
+
* Fixing it needs a design (e.g. find the manifest bound to the payload
|
|
425
|
+
* `session_id` among the repo's worktrees). Revisit when an entered-worktree
|
|
426
|
+
* session dispatches `isolation: "worktree"` agents under a manifest.
|
|
427
|
+
* - Rung 2 is `cd`-relocatable: a session whose `cwd` sits in ANOTHER repo (a
|
|
428
|
+
* submodule, a nested or sibling checkout) is checked against that repo's
|
|
429
|
+
* manifest — usually none, so its gates allow — where the launch dir's
|
|
430
|
+
* manifest used to apply. Same relocation `pre-task-scope-disjoint` has
|
|
431
|
+
* keyed its ledger on since #1489. Revisit if an `orchestrator.scope.*`
|
|
432
|
+
* record or a review ever shows an out-of-scope write made after such a `cd`.
|
|
433
|
+
* - A session worktree NAMED like a harness one (`agent-` + 8 or more hex) is
|
|
434
|
+
* lifted to its parent — the pre-#1492 launch-dir behaviour for that one
|
|
435
|
+
* session, not a new failure. Revisit if the harness changes its naming.
|
|
436
|
+
* - A Cursor/Pi bridge session follows its payload `cwd`, as it did before
|
|
437
|
+
* #1492: the bridges hand over no launch dir distinct from that `cwd`, so a
|
|
438
|
+
* bridge session whose `cwd` moves into a subdirectory without a manifest is
|
|
439
|
+
* unenforced there. Not a new failure — the pre-#1492 bridge answer. Revisit
|
|
440
|
+
* when a bridge hands over a stable workspace root separately from the
|
|
441
|
+
* payload `cwd` — then clamp on that instead.
|
|
442
|
+
*
|
|
443
|
+
* @param {string|undefined} cwd the hook payload's `cwd`
|
|
444
|
+
* @param {string} [toplevel] `git rev-parse --show-toplevel` of `cwd`, `''` when unknown
|
|
445
|
+
* @returns {string} Absolute path
|
|
446
|
+
*/
|
|
447
|
+
export function resolveSessionRoot(cwd, toplevel = '') {
|
|
448
|
+
if (typeof cwd !== 'string' || cwd.trim() === '') return resolveProjectDir();
|
|
449
|
+
const repoRoot = (typeof toplevel === 'string' ? toplevel : '') || dotGitAncestor(cwd);
|
|
450
|
+
if (!repoRoot) return launchDirFromEnv() || cwd;
|
|
451
|
+
const root = _liftHarnessAgentWorktree(repoRoot);
|
|
452
|
+
const launch = launchDirFromEnv();
|
|
453
|
+
return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
|
|
454
|
+
}
|
|
455
|
+
|
|
252
456
|
// ---------------------------------------------------------------------------
|
|
253
457
|
// resolveStateDir
|
|
254
458
|
// ---------------------------------------------------------------------------
|
|
@@ -260,7 +260,9 @@ async function resolveLatestVersion({ cacheDir, now, fetchImpl }) {
|
|
|
260
260
|
* @type {Record<"claude"|"codex"|"cursor"|"pi", string>}
|
|
261
261
|
*/
|
|
262
262
|
const PLATFORM_UPDATE_INSTRUCTIONS = {
|
|
263
|
-
claude:
|
|
263
|
+
claude:
|
|
264
|
+
'/plugin update session-orchestrator@kanevry, then restart Claude Code ' +
|
|
265
|
+
'(from a clone: `npm run update:local` also installs the deps Claude Code refuses — docs/install.md § Upgrade)',
|
|
264
266
|
codex:
|
|
265
267
|
'run `codex plugin marketplace upgrade kanevry && codex plugin add session-orchestrator@kanevry` ' +
|
|
266
268
|
'(docs/codex-setup.md), then restart Codex',
|
|
@@ -25,17 +25,18 @@
|
|
|
25
25
|
import { spawn } from 'node:child_process';
|
|
26
26
|
import { createHash } from 'node:crypto';
|
|
27
27
|
import {
|
|
28
|
-
appendFileSync,
|
|
29
28
|
existsSync,
|
|
30
29
|
lstatSync,
|
|
31
30
|
mkdirSync,
|
|
32
|
-
readFileSync,
|
|
33
31
|
renameSync,
|
|
34
32
|
unlinkSync,
|
|
35
33
|
writeFileSync,
|
|
36
34
|
} from 'node:fs';
|
|
37
35
|
import path from 'node:path';
|
|
38
36
|
|
|
37
|
+
import { NOT_A_REGULAR_FILE, appendNoFollow } from './orphan-reaper/no-follow-append.mjs';
|
|
38
|
+
import { readTailWindow } from './tail-window.mjs';
|
|
39
|
+
|
|
39
40
|
/**
|
|
40
41
|
* Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
|
|
41
42
|
* used by the gate's group ladder here and by `wave-executor/foreign-dispatch.mjs`
|
|
@@ -490,6 +491,26 @@ function ledgerPathFor(repoRoot) {
|
|
|
490
491
|
return path.join(repoRoot, ...GATE_PROCESS_LEDGER_RELPATH.split('/'));
|
|
491
492
|
}
|
|
492
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Read the whole ledger without following a link and without blocking on a
|
|
496
|
+
* FIFO: `readTailWindow`'s `noFollow` open with a window larger than any file,
|
|
497
|
+
* so a regular ledger reads exactly as `readFileSync` read it. A plain
|
|
498
|
+
* `readFileSync` FOLLOWED a symlinked ledger (a foreign file parsed as kill
|
|
499
|
+
* candidates) and blocked for good in `open` on a planted FIFO — inside the
|
|
500
|
+
* gate's own registration path (#1489). THROWS like `readFileSync`: ENOENT,
|
|
501
|
+
* ELOOP for a symlink, `ERR_NOT_REGULAR_FILE` for a FIFO or directory.
|
|
502
|
+
*
|
|
503
|
+
* Named ceiling (BV-004): the file is read whole, as before — fine for a ledger
|
|
504
|
+
* pruned to 24 h (one line per gate command); revisit if it ever stops being
|
|
505
|
+
* pruned.
|
|
506
|
+
*
|
|
507
|
+
* @param {string} target
|
|
508
|
+
* @returns {string}
|
|
509
|
+
*/
|
|
510
|
+
function readLedgerNoFollow(target) {
|
|
511
|
+
return readTailWindow(target, Number.MAX_SAFE_INTEGER, { noFollow: true }).text;
|
|
512
|
+
}
|
|
513
|
+
|
|
493
514
|
/**
|
|
494
515
|
* Append one {@link GateProcessRecord} to the gate-process ledger.
|
|
495
516
|
*
|
|
@@ -499,6 +520,12 @@ function ledgerPathFor(repoRoot) {
|
|
|
499
520
|
* vanishing (a ledger that silently stops being written is indistinguishable
|
|
500
521
|
* from a host with no gate processes).
|
|
501
522
|
*
|
|
523
|
+
* The write is {@link appendNoFollow}, never `appendFileSync(path)`: that
|
|
524
|
+
* followed a symlinked ledger, so every gate registration landed in the link's
|
|
525
|
+
* target, and its open of a planted FIFO blocked until a reader appeared — the
|
|
526
|
+
* gate hung in its own registration path (#1489). A link, FIFO, directory or
|
|
527
|
+
* hard-linked ledger is now refused with the same one WARN line.
|
|
528
|
+
*
|
|
502
529
|
* @param {string} repoRoot
|
|
503
530
|
* @param {GateProcessRecord} record
|
|
504
531
|
* @param {object} [opts]
|
|
@@ -513,7 +540,7 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
|
513
540
|
if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
|
|
514
541
|
const line = `${JSON.stringify(record)}\n`;
|
|
515
542
|
if (appendFn) appendFn(target, line);
|
|
516
|
-
else
|
|
543
|
+
else appendNoFollow(target, line);
|
|
517
544
|
} catch (err) {
|
|
518
545
|
process.stderr.write(
|
|
519
546
|
`process-group: could not record gate process in ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
|
|
@@ -532,14 +559,24 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
|
532
559
|
* counted separately — they are not candidates for anything, and their PIDs are
|
|
533
560
|
* the most likely to have been recycled.
|
|
534
561
|
*
|
|
562
|
+
* A symlinked, FIFO or otherwise non-regular ledger yields NO records, like an
|
|
563
|
+
* unreadable one ({@link readLedgerNoFollow}): never a link target's lines as
|
|
564
|
+
* candidates, never a blocked open — no records name no process group to
|
|
565
|
+
* signal. But it is not reported as empty (#1498): `state` tells the three
|
|
566
|
+
* outcomes apart — `read`, `absent` (no ledger yet, the ordinary case) and
|
|
567
|
+
* `unreadable` (refused or failed) — because the scan's `ledger-unreadable`
|
|
568
|
+
* skip was unreachable while a refused ledger looked exactly like a clean one.
|
|
569
|
+
*
|
|
535
570
|
* @param {string} repoRoot
|
|
536
571
|
* @param {object} [opts]
|
|
537
572
|
* @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
|
|
538
573
|
* return '' for a missing file.
|
|
539
574
|
* @param {number} [opts.nowMs]
|
|
540
575
|
* @param {number} [opts.maxAgeMs]
|
|
541
|
-
* @returns {{records: GateProcessRecord[], malformedLines: number, expired: number
|
|
542
|
-
*
|
|
576
|
+
* @returns {{records: GateProcessRecord[], malformedLines: number, expired: number,
|
|
577
|
+
* state: 'read'|'absent'|'unreadable'}}
|
|
578
|
+
* `records` are the fresh, parseable entries in source order; empty unless
|
|
579
|
+
* `state` is `read`.
|
|
543
580
|
*/
|
|
544
581
|
export function readGateProcessLedger(repoRoot, {
|
|
545
582
|
readFn,
|
|
@@ -547,12 +584,19 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
547
584
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
548
585
|
} = {}) {
|
|
549
586
|
const target = ledgerPathFor(repoRoot);
|
|
550
|
-
let raw
|
|
587
|
+
let raw;
|
|
551
588
|
try {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
589
|
+
// No `existsSync` pre-check: it FOLLOWS a link, so a dangling symlink read
|
|
590
|
+
// as "no ledger yet" while `appendNoFollow` refused it on every
|
|
591
|
+
// registration (#1498 review). The no-follow open itself tells the cases
|
|
592
|
+
// apart — ENOENT only when nothing is at the path.
|
|
593
|
+
raw = readFn ? (readFn(target) ?? '') : readLedgerNoFollow(target);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
// ENOENT: no ledger yet, or one that vanished (or a `readFn` reporting a
|
|
596
|
+
// missing file). Anything else — ELOOP (a symlink, dangling or not),
|
|
597
|
+
// ERR_NOT_REGULAR_FILE, EACCES — was refused.
|
|
598
|
+
const state = err?.code === 'ENOENT' ? 'absent' : 'unreadable';
|
|
599
|
+
return { records: [], malformedLines: 0, expired: 0, state };
|
|
556
600
|
}
|
|
557
601
|
|
|
558
602
|
/** @type {GateProcessRecord[]} */
|
|
@@ -580,7 +624,7 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
580
624
|
records.push(parsed);
|
|
581
625
|
}
|
|
582
626
|
|
|
583
|
-
return { records, malformedLines, expired };
|
|
627
|
+
return { records, malformedLines, expired, state: 'read' };
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
/**
|
|
@@ -594,17 +638,19 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
594
638
|
* ledger linked to `events.jsonl` emptied the link's target (CWE-59, reproduced
|
|
595
639
|
* 2026-10-01). `rename` replaces the directory entry itself, never a link's
|
|
596
640
|
* target, and the `wx` flag refuses a pre-planted file or link at the tmp name.
|
|
597
|
-
*
|
|
598
|
-
*
|
|
641
|
+
* The ONE copy (#1498): `orphan-reaper/reaper-audit.mjs` imports it for the
|
|
642
|
+
* audit prune — both ledgers its scan housekeeping rewrites. It lives here, not
|
|
643
|
+
* there, because this module imports nothing from `orphan-reaper/` beyond the
|
|
644
|
+
* fs-only `no-follow-append.mjs`, so the graph stays acyclic, and every
|
|
645
|
+
* importer of the audit module already loads this one.
|
|
599
646
|
*
|
|
600
647
|
* @param {string} target
|
|
601
648
|
* @param {string} body
|
|
602
|
-
* @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} io
|
|
649
|
+
* @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} [io]
|
|
650
|
+
* fs seam for tests; defaults to `node:fs`.
|
|
603
651
|
*/
|
|
604
|
-
function replaceRegularFile(target, body, io) {
|
|
605
|
-
if (!io.lstatSync(target).isFile())
|
|
606
|
-
throw new Error('not a regular file (a symlink is never written through) — left untouched');
|
|
607
|
-
}
|
|
652
|
+
export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync, renameSync, unlinkSync }) {
|
|
653
|
+
if (!io.lstatSync(target).isFile()) throw new Error(NOT_A_REGULAR_FILE);
|
|
608
654
|
const tmp = `${target}.tmp-${process.pid}`;
|
|
609
655
|
try {
|
|
610
656
|
io.writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
@@ -630,8 +676,10 @@ function replaceRegularFile(target, body, io) {
|
|
|
630
676
|
*
|
|
631
677
|
* In-process fs only — never a shell `rm`/`mv` (PSA-003, and
|
|
632
678
|
* `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
|
|
633
|
-
* The
|
|
634
|
-
*
|
|
679
|
+
* The read is {@link readLedgerNoFollow} and the rewrite goes through
|
|
680
|
+
* {@link replaceRegularFile}: a symlinked, FIFO or otherwise non-regular ledger
|
|
681
|
+
* is refused with one WARN and left untouched — refused at the READ, so a link
|
|
682
|
+
* target is never read and a FIFO never blocks the gate's registration path.
|
|
635
683
|
*
|
|
636
684
|
* Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
|
|
637
685
|
* append, so a line appended between the read and the write is lost. Acceptable
|
|
@@ -655,13 +703,26 @@ export function pruneGateProcessLedger(repoRoot, {
|
|
|
655
703
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
656
704
|
fs: fsSeam,
|
|
657
705
|
} = {}) {
|
|
658
|
-
const io = fsSeam ?? {
|
|
706
|
+
const io = fsSeam ?? {
|
|
707
|
+
existsSync,
|
|
708
|
+
readFileSync: readLedgerNoFollow,
|
|
709
|
+
writeFileSync,
|
|
710
|
+
lstatSync,
|
|
711
|
+
renameSync,
|
|
712
|
+
unlinkSync,
|
|
713
|
+
};
|
|
659
714
|
const target = ledgerPathFor(repoRoot);
|
|
660
715
|
let raw;
|
|
661
716
|
try {
|
|
662
717
|
if (!io.existsSync(target)) return 0;
|
|
663
718
|
raw = io.readFileSync(target, 'utf8');
|
|
664
|
-
} catch {
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// A refused ledger (ELOOP: a symlink; ERR_NOT_REGULAR_FILE: a FIFO, a
|
|
721
|
+
// directory) gets the same one WARN the rewrite gives it; an unreadable or
|
|
722
|
+
// vanished one stays silent, as before.
|
|
723
|
+
if (err?.code === 'ELOOP' || err?.code === 'ERR_NOT_REGULAR_FILE') {
|
|
724
|
+
process.stderr.write(`process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${NOT_A_REGULAR_FILE}\n`);
|
|
725
|
+
}
|
|
665
726
|
return 0;
|
|
666
727
|
}
|
|
667
728
|
|
|
@@ -141,8 +141,11 @@ import { resolveBaselineRange, isQueryFailure } from './vcs-repo-spec.mjs';
|
|
|
141
141
|
// F1 bug this filter exists to prevent — a caller could hand
|
|
142
142
|
// `writeBaseline()` an unfiltered count with no `filterExcluded()` pass at
|
|
143
143
|
// all). `computeDrift()` filters the numerator at measure time by running
|
|
144
|
-
// the live
|
|
145
|
-
// `filterExcluded()` helper.
|
|
144
|
+
// the live change set (committed range ∪ working tree ∪ untracked, see its
|
|
145
|
+
// JSDoc) through the same `filterExcluded()` helper. One classification sits
|
|
146
|
+
// OUTSIDE this list on purpose: untracked paths under `.orchestrator/` (see
|
|
147
|
+
// `isUntrackedRuntimeWrite()` below) — it keys on tracked status, which only
|
|
148
|
+
// the live numerator has, so a pattern here could not express it.
|
|
146
149
|
// Deliberately narrows skills/session-end/plan-verification.md:42-45's
|
|
147
150
|
// per-session-state exclusion from "the whole .claude/ directory" down to
|
|
148
151
|
// just the session ARTEFACTS — `.claude/rules/**` stays COUNTED because a
|
|
@@ -165,6 +168,24 @@ export const DRIFT_EXCLUDE_PATTERNS = [
|
|
|
165
168
|
'.codex/STATE.md', '.codex/wave-scope.json', '.codex/metrics/**',
|
|
166
169
|
'.cursor/STATE.md', '.cursor/wave-scope.json', '.cursor/metrics/**',
|
|
167
170
|
'.pi/STATE.md', '.pi/wave-scope.json', '.pi/metrics/**',
|
|
171
|
+
// The plugin's OWN mid-session writes. They only became visible to the
|
|
172
|
+
// numerator when it started reading the working tree and untracked files
|
|
173
|
+
// (#1027). Two classes are excluded HERE, whatever their tracked status:
|
|
174
|
+
// - `.orchestrator/metrics/**` — a consumer repo keeps its `*.jsonl`
|
|
175
|
+
// TRACKED by design (skills/bootstrap/standard-template.md "Gitignore
|
|
176
|
+
// guidance") and hooks append to it all session.
|
|
177
|
+
// - `<state-dir>/filescopes/**` and `<state-dir>/worktrees/**` — bootstrap
|
|
178
|
+
// writes no ignore line for either. An agent worktree is a nested repo,
|
|
179
|
+
// so `ls-files --others` lists each as ONE `.claude/worktrees/<id>/`
|
|
180
|
+
// entry (measured 2026-10-02): one false file per isolated agent.
|
|
181
|
+
// Every OTHER plugin write under `.orchestrator/` (runtime/, tmp/, debug/,
|
|
182
|
+
// session.lock, current-session.json, ...) is excluded by
|
|
183
|
+
// `isUntrackedRuntimeWrite()` while untracked, which is how a consumer repo
|
|
184
|
+
// holds them. Its deliverables (`steering/`, `policy/`, `bootstrap.lock`)
|
|
185
|
+
// are tracked and count.
|
|
186
|
+
'.claude/filescopes/**', '.codex/filescopes/**', '.cursor/filescopes/**', '.pi/filescopes/**',
|
|
187
|
+
'.claude/worktrees/**', '.codex/worktrees/**', '.cursor/worktrees/**', '.pi/worktrees/**',
|
|
188
|
+
'.orchestrator/metrics/**',
|
|
168
189
|
];
|
|
169
190
|
|
|
170
191
|
// ---------------------------------------------------------------------------
|
|
@@ -218,6 +239,38 @@ function filterExcluded(files) {
|
|
|
218
239
|
);
|
|
219
240
|
}
|
|
220
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Is this UNTRACKED path the plugin's own runtime output? Same rule as
|
|
244
|
+
* `isOwnRuntimeArtifact()` in scripts/lib/project-hygiene.mjs (untracked
|
|
245
|
+
* under `.orchestrator/` = ours), mirrored rather than imported: it is not
|
|
246
|
+
* exported, and importing that module would pull js-yaml and the command
|
|
247
|
+
* blocker into this one for a one-line prefix test. Applied to the
|
|
248
|
+
* `ls-files --others` source only — a tracked `.orchestrator/` file is a
|
|
249
|
+
* deliverable or a ledger, and `DRIFT_EXCLUDE_PATTERNS` decides which.
|
|
250
|
+
*
|
|
251
|
+
* Ceiling (BV-004): a runtime file a consumer has COMMITTED (e.g. a
|
|
252
|
+
* `session.lock` swept in by `git add -A`) counts whenever the plugin
|
|
253
|
+
* rewrites it, and a NEW `steering/`/`policy/` file counts only once it is
|
|
254
|
+
* tracked. Revisit if a drift WARN traces to a tracked runtime file, or a
|
|
255
|
+
* missed WARN to an uncommitted steering/policy deliverable.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} file repo-root-relative path from `git ls-files --others`
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
function isUntrackedRuntimeWrite(file) {
|
|
261
|
+
return file.startsWith('.orchestrator/');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Split git `--name-only`/`ls-files` stdout into trimmed non-empty paths. */
|
|
265
|
+
function outputPaths(stdout) {
|
|
266
|
+
return stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Per-query bound for the three numerator git calls — the same 10 s that
|
|
270
|
+
// scripts/lib/project-hygiene.mjs gives its read-only git queries. A timeout
|
|
271
|
+
// throws like any other failure and lands in the same skip.
|
|
272
|
+
const DRIFT_GIT_TIMEOUT_MS = 10_000;
|
|
273
|
+
|
|
221
274
|
/**
|
|
222
275
|
* Internal: read + parse STATE.md's frontmatter exactly once. Shared by
|
|
223
276
|
* `readBaseline()` and `computeDrift()` so a single `computeDrift()` call
|
|
@@ -464,13 +517,38 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
|
|
|
464
517
|
}
|
|
465
518
|
|
|
466
519
|
/**
|
|
467
|
-
* Compute the S2 warn-only scope-drift ratio: how many files
|
|
468
|
-
*
|
|
520
|
+
* Compute the S2 warn-only scope-drift ratio: how many files differ in this
|
|
521
|
+
* checkout — committed since the session's frozen `session-start-ref`, plus
|
|
522
|
+
* whatever is uncommitted or untracked right now — filtered through
|
|
469
523
|
* `DRIFT_EXCLUDE_PATTERNS`, against the `plannedFiles` count frozen by
|
|
470
524
|
* `writeBaseline()`. Sync (`readFileSync` + `execFileSync('git', ...)`
|
|
471
525
|
* only). Never throws, never denies — this is a WARN-only tripwire; the
|
|
472
526
|
* CALLER decides whether/how to surface `breached`.
|
|
473
527
|
*
|
|
528
|
+
* Numerator (#1027): the deduplicated union of three git queries — the
|
|
529
|
+
* committed range diff, the working tree against HEAD (staged + unstaged),
|
|
530
|
+
* and untracked files (`ls-files --others --exclude-standard`, minus the
|
|
531
|
+
* plugin's own untracked `.orchestrator/` writes, see
|
|
532
|
+
* `isUntrackedRuntimeWrite()`). Until #1027 only the first was read, so
|
|
533
|
+
* uncommitted edits and every NEW module — the typical shape of an
|
|
534
|
+
* overshoot — were invisible until someone committed them. All three must
|
|
535
|
+
* succeed: if any fails (including ENOBUFS and the per-query timeout) the
|
|
536
|
+
* result is the `unresolvable-ref` skip, never a ratio over a partial
|
|
537
|
+
* numerator. That reason is deliberately not split by cause: its readers
|
|
538
|
+
* (skills/wave-executor/references/wave-loop-review.md step 7a,
|
|
539
|
+
* skills/session-end/plan-verification.md step 5) render every skip
|
|
540
|
+
* silently or verbatim and none branches on it.
|
|
541
|
+
*
|
|
542
|
+
* Named ceiling (BV-004) — over-report: only the committed source is tied to
|
|
543
|
+
* `session-start-ref`. The working-tree and untracked sources read the
|
|
544
|
+
* checkout as it is NOW, so a file that was already dirty when the session
|
|
545
|
+
* started, and a same-checkout peer session's files, count as this session's
|
|
546
|
+
* drift. Fixing it means persisting a start-dirty snapshot at freeze time and
|
|
547
|
+
* subtracting it — a storage change, out of scope for this warn-only
|
|
548
|
+
* tripwire. Revisit if a drift WARN traces to pre-session dirt or to a
|
|
549
|
+
* peer's files (`git status` at session start shows the former,
|
|
550
|
+
* PSA-001 signals the latter).
|
|
551
|
+
*
|
|
474
552
|
* Skip precedence (first match wins, so `reason` is deterministic):
|
|
475
553
|
* `no-state-md` → `unreadable-state-md` → `no-baseline` →
|
|
476
554
|
* `stale-baseline` → (`no-baseline-ref` | `unresolvable-ref`)
|
|
@@ -509,13 +587,14 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
|
|
|
509
587
|
*
|
|
510
588
|
* Named ceiling (BV-004) on the local-branch tail of the resolution chain:
|
|
511
589
|
* when the chain lands on a LOCAL `main`/`master` and HEAD is already that
|
|
512
|
-
* branch, `main...HEAD` has merge-base === HEAD and the
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
590
|
+
* branch, `main...HEAD` has merge-base === HEAD and the COMMITTED source is
|
|
591
|
+
* empty, so the session's commits are invisible and the ratio counts only
|
|
592
|
+
* the uncommitted and untracked files instead of skipping. That is the
|
|
593
|
+
* honest floor of the available information (no remote-tracking ref exists
|
|
594
|
+
* to say where the session started); it can only under-report, and an
|
|
595
|
+
* under-report is as silent as a skip under `wave-loop-review.md`'s
|
|
596
|
+
* WARN-on-breach rule. Revisit if this function ever gains a non-warn
|
|
597
|
+
* consumer that treats `skipped:false` as "measured successfully".
|
|
519
598
|
*
|
|
520
599
|
* @param {object} args
|
|
521
600
|
* @param {string|undefined} args.repoRoot
|
|
@@ -578,26 +657,38 @@ export function computeDrift({ repoRoot, threshold = 2.0 } = {}) {
|
|
|
578
657
|
refUsed = resolved.range;
|
|
579
658
|
}
|
|
580
659
|
|
|
581
|
-
|
|
660
|
+
// Untracked files can be numerous, so the 1 MiB default buffer would turn
|
|
661
|
+
// a large tree into an ENOBUFS skip.
|
|
662
|
+
const gitOpts = { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: DRIFT_GIT_TIMEOUT_MS };
|
|
663
|
+
const changedFiles = new Set();
|
|
582
664
|
try {
|
|
583
|
-
|
|
665
|
+
const committed = execFileSync('git', ['diff', '--name-only', diffRange], gitOpts);
|
|
666
|
+
const workingTree = execFileSync('git', ['diff', '--name-only', 'HEAD'], gitOpts);
|
|
667
|
+
// `--full-name` + `:/` keep these paths in the same repo-root-relative
|
|
668
|
+
// space as `git diff --name-only`, even if `cwd` is a subdirectory.
|
|
669
|
+
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--full-name', '--', ':/'], gitOpts);
|
|
670
|
+
for (const file of [...outputPaths(committed), ...outputPaths(workingTree)]) changedFiles.add(file);
|
|
671
|
+
for (const file of outputPaths(untracked)) {
|
|
672
|
+
if (!isUntrackedRuntimeWrite(file)) changedFiles.add(file);
|
|
673
|
+
}
|
|
584
674
|
} catch {
|
|
585
|
-
// The range resolved but
|
|
586
|
-
// `session-start-ref` (rebase, force-push, deleted commit),
|
|
587
|
-
//
|
|
588
|
-
//
|
|
675
|
+
// The range resolved but a numerator query failed — a present-but-dead
|
|
676
|
+
// `session-start-ref` (rebase, force-push, deleted commit), a resolved
|
|
677
|
+
// base that vanished between resolution and diff, a working-tree or
|
|
678
|
+
// untracked query git could not answer, output over `maxBuffer`
|
|
679
|
+
// (ENOBUFS), or a query past `DRIFT_GIT_TIMEOUT_MS`. None can produce a
|
|
680
|
+
// trustworthy numerator, and a partial one must not be reported as
|
|
681
|
+
// measured. All share `unresolvable-ref`: every reader renders a skip
|
|
682
|
+
// silently and none branches on the reason (census in the JSDoc above).
|
|
589
683
|
return { ok: true, skipped: true, reason: 'unresolvable-ref' };
|
|
590
684
|
}
|
|
591
685
|
|
|
592
|
-
const changedFiles = stdout
|
|
593
|
-
.split('\n')
|
|
594
|
-
.map((line) => line.trim())
|
|
595
|
-
.filter((line) => line.length > 0);
|
|
596
|
-
|
|
597
686
|
// Same `filterExcluded()` primitive `writeBaseline()`'s denominator
|
|
598
|
-
// (`countPlannedFiles()`) calls (#894 review finding F1)
|
|
599
|
-
//
|
|
600
|
-
|
|
687
|
+
// (`countPlannedFiles()`) calls (#894 review finding F1), so the pattern
|
|
688
|
+
// filter on both sides of the ratio is one function. The untracked
|
|
689
|
+
// `.orchestrator/` rule above is numerator-only by construction: a planned
|
|
690
|
+
// path carries no tracked status to test.
|
|
691
|
+
const actualFiles = filterExcluded([...changedFiles]).length;
|
|
601
692
|
|
|
602
693
|
const plannedFilesRaw = baseline.plannedFiles;
|
|
603
694
|
const plannedFiles = typeof plannedFilesRaw === 'number' && Number.isFinite(plannedFilesRaw)
|
|
@@ -51,6 +51,7 @@ import { join, resolve } from 'node:path';
|
|
|
51
51
|
|
|
52
52
|
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
53
53
|
import { isMainModule } from './is-main-module.mjs';
|
|
54
|
+
import { isCoordinatorRecordId } from './scope-gate.mjs';
|
|
54
55
|
|
|
55
56
|
/** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
|
|
56
57
|
export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
|
|
@@ -329,6 +330,11 @@ function parseJsonl(raw) {
|
|
|
329
330
|
* only iterates the entries is unaffected. (A file that parses to a non-array or
|
|
330
331
|
* to an empty scope stays a silent skip: an empty scope is legitimate.)
|
|
331
332
|
*
|
|
333
|
+
* `coordinator.json` is skipped too (#1493.2): the coordinator's planned direct
|
|
334
|
+
* edits are a declaration, never a dispatch, so no injection or echo can ever
|
|
335
|
+
* claim its digest — digesting it reported the coordinator's own scope as
|
|
336
|
+
* `injection-missing` on every wave where it planned an edit.
|
|
337
|
+
*
|
|
332
338
|
* @param {string} stateDir
|
|
333
339
|
* @param {number} wave
|
|
334
340
|
* @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
|
|
@@ -347,6 +353,7 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
|
|
|
347
353
|
for (const name of names) {
|
|
348
354
|
const file = typeof name === 'string' ? name : name?.name;
|
|
349
355
|
if (typeof file !== 'string' || !file.endsWith('.json')) continue;
|
|
356
|
+
if (isCoordinatorRecordId(file.slice(0, -'.json'.length))) continue;
|
|
350
357
|
let parsed;
|
|
351
358
|
try {
|
|
352
359
|
parsed = JSON.parse(readFile(join(dir, file), 'utf8'));
|