session-orchestrator 5.2.0 → 5.3.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/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -288,6 +288,29 @@ const SHAPE_NONE = 'none';
|
|
|
288
288
|
/** The per-dispatch observability record (#1092) — see § Observability. */
|
|
289
289
|
const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The worktree-base observability record (#1413).
|
|
293
|
+
*
|
|
294
|
+
* Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
|
|
295
|
+
* written for EVERY `isolation: "worktree"` dispatch rather than only the
|
|
296
|
+
* alarming one. That is HR-105 applied at the source: a numerator-only stream
|
|
297
|
+
* cannot tell "genuinely rare" from "silently broken", and the firing rate this
|
|
298
|
+
* warning must stay under is then unfalsifiable. With every outcome in one
|
|
299
|
+
* stream the rate is `stale:true / all records of this name`.
|
|
300
|
+
*
|
|
301
|
+
* #1424 widened "every outcome" from two to three: the `stale: true|false`
|
|
302
|
+
* split was already covered, but each NON-measurement (peer STATE.md, no
|
|
303
|
+
* STATE.md, no `session-start-ref`, a git failure) returned early and emitted
|
|
304
|
+
* nothing — so the gate in front of the split reproduced exactly the
|
|
305
|
+
* zero-records ambiguity the split had removed. Those now carry
|
|
306
|
+
* `stale: null` plus a `skipped` reason, and only `isolation !== "worktree"`
|
|
307
|
+
* stays silent (HR-101: it is ~90% of all dispatches).
|
|
308
|
+
*/
|
|
309
|
+
const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
|
|
310
|
+
|
|
311
|
+
/** State-dir candidates, in the same order `waveKeyOf()` probes them. */
|
|
312
|
+
const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
|
|
313
|
+
|
|
291
314
|
/**
|
|
292
315
|
* The receive-side instruction line `renderScopeEchoInstruction()` renders and
|
|
293
316
|
* the coordinator appends immediately after the fenced block
|
|
@@ -981,6 +1004,163 @@ export function listTrackedFiles(cwd) {
|
|
|
981
1004
|
}
|
|
982
1005
|
}
|
|
983
1006
|
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
// Stale worktree base (#1413) — a WARNING, never a decision
|
|
1009
|
+
//
|
|
1010
|
+
// `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
|
|
1011
|
+
// agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
|
|
1012
|
+
// no base-ref field (the 147-dispatch key census in the module docblock). The
|
|
1013
|
+
// base is the SESSION-START commit, and it does not follow a mid-session commit:
|
|
1014
|
+
// measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
|
|
1015
|
+
// `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
|
|
1016
|
+
// edits the OLD code, and its test run is structurally red on top — `validate-
|
|
1017
|
+
// plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
|
|
1018
|
+
// against `git show HEAD:` inside the stale worktree.
|
|
1019
|
+
//
|
|
1020
|
+
// We have no lever on the base, so the only available fix is to be LOUD. This
|
|
1021
|
+
// never touches the verdict: the two functions below are TOTAL (every failure
|
|
1022
|
+
// resolves to silence), and the caller ignores their result for the decision.
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Facts for the worktree-base check, as a DISCRIMINATED record with three
|
|
1027
|
+
* outcomes rather than two (#1424):
|
|
1028
|
+
*
|
|
1029
|
+
* - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
|
|
1030
|
+
* - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
|
|
1031
|
+
* branch, but the question could not be answered honestly. Still emitted,
|
|
1032
|
+
* because HR-105 needs the denominator: while every such case returned bare
|
|
1033
|
+
* `null` and the caller emitted nothing, "the check never had anything to
|
|
1034
|
+
* say" and "the check was silently disarmed" both read as zero records —
|
|
1035
|
+
* precisely the shape this event exists to close.
|
|
1036
|
+
* - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
|
|
1037
|
+
* outcome that must stay so: `isolation` appeared in 14 of 147 measured
|
|
1038
|
+
* dispatches, so recording the other ~90% would put the denominator on the
|
|
1039
|
+
* whole hot dispatch path (HR-101).
|
|
1040
|
+
*
|
|
1041
|
+
* IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
|
|
1042
|
+
* lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
|
|
1043
|
+
* peer session. It is read only when STATE.md's own `session-id` equals this
|
|
1044
|
+
* process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
|
|
1045
|
+
* a process-local witness REPLACES the shared one, never unions with it. On
|
|
1046
|
+
* mismatch, absence or any read failure this reports `stale: null`: a confident
|
|
1047
|
+
* warning about somebody else's session is worse than no warning at all. The
|
|
1048
|
+
* test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
|
|
1049
|
+
*
|
|
1050
|
+
* `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
|
|
1051
|
+
* (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
|
|
1052
|
+
* with zero imports and already inside the committed hook-import closure, so
|
|
1053
|
+
* reuse costs no new edge, and binding it late keeps a load failure catchable
|
|
1054
|
+
* instead of disarming the guard at ESM link time (§ #993 late-bound deps).
|
|
1055
|
+
*
|
|
1056
|
+
* @param {{tool_input?: unknown}} input — the raw hook payload
|
|
1057
|
+
* @param {string} projectDir
|
|
1058
|
+
* @param {string} sessionId — `input.session_id`, or `'no-session'`
|
|
1059
|
+
* @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
|
|
1060
|
+
* subagent_type?: string}|{stale: null, skipped: string}|null>}
|
|
1061
|
+
*/
|
|
1062
|
+
async function worktreeBaseFacts(input, projectDir, sessionId) {
|
|
1063
|
+
// The applicability gate sits OUTSIDE the try on purpose: everything below it
|
|
1064
|
+
// resolves to a `skipped` RECORD, so a throw here must not be able to mint one
|
|
1065
|
+
// for a dispatch that was never on the `worktree` branch at all.
|
|
1066
|
+
const toolInput = input?.tool_input;
|
|
1067
|
+
if (toolInput === null || typeof toolInput !== 'object') return null;
|
|
1068
|
+
if (toolInput.isolation !== 'worktree') return null;
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
const ownId = sessionId && sessionId !== 'no-session'
|
|
1072
|
+
? sessionId
|
|
1073
|
+
: (process.env.CLAUDE_CODE_SESSION_ID || '');
|
|
1074
|
+
if (!ownId) return { stale: null, skipped: 'no-own-id' };
|
|
1075
|
+
|
|
1076
|
+
const { parseStateMd } = await import(
|
|
1077
|
+
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
// #1424: the identity test lives INSIDE the loop. Breaking at the first
|
|
1081
|
+
// PARSEABLE STATE.md and testing `session-id` afterwards meant a single
|
|
1082
|
+
// left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
|
|
1083
|
+
// STATE_DIR_CANDIDATES — disarmed the check for a session whose own
|
|
1084
|
+
// `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
|
|
1085
|
+
// keeps the two silences apart afterwards: "nothing to read" is a different
|
|
1086
|
+
// fact from "read, and none of it was mine".
|
|
1087
|
+
let frontmatter = null;
|
|
1088
|
+
let sawStateMd = false;
|
|
1089
|
+
for (const dir of STATE_DIR_CANDIDATES) {
|
|
1090
|
+
try {
|
|
1091
|
+
const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
|
|
1092
|
+
if (!parsed?.frontmatter) continue;
|
|
1093
|
+
sawStateMd = true;
|
|
1094
|
+
if (parsed.frontmatter['session-id'] !== ownId) continue;
|
|
1095
|
+
frontmatter = parsed.frontmatter;
|
|
1096
|
+
break;
|
|
1097
|
+
} catch { /* try the next state dir */ }
|
|
1098
|
+
}
|
|
1099
|
+
if (frontmatter === null) {
|
|
1100
|
+
return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const startRef = typeof frontmatter['session-start-ref'] === 'string'
|
|
1104
|
+
? frontmatter['session-start-ref'].trim()
|
|
1105
|
+
: '';
|
|
1106
|
+
if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
|
|
1107
|
+
|
|
1108
|
+
let head = '';
|
|
1109
|
+
try {
|
|
1110
|
+
head = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
1111
|
+
cwd: projectDir,
|
|
1112
|
+
encoding: 'utf8',
|
|
1113
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1114
|
+
}).trim();
|
|
1115
|
+
} catch {
|
|
1116
|
+
return { stale: null, skipped: 'git-error' };
|
|
1117
|
+
}
|
|
1118
|
+
if (head === '') return { stale: null, skipped: 'git-error' };
|
|
1119
|
+
|
|
1120
|
+
const facts = { head, session_start_ref: startRef, stale: head !== startRef };
|
|
1121
|
+
if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
|
|
1122
|
+
facts.subagent_type = toolInput.subagent_type;
|
|
1123
|
+
}
|
|
1124
|
+
return facts;
|
|
1125
|
+
} catch {
|
|
1126
|
+
// Total by construction: no parser, an unreadable state dir, anything else.
|
|
1127
|
+
// Still a RECORD rather than silence — the dispatch WAS on the `worktree`
|
|
1128
|
+
// branch, and a broken probe that emits nothing is indistinguishable from a
|
|
1129
|
+
// probe that had nothing to report (#1424 / HR-105).
|
|
1130
|
+
return { stale: null, skipped: 'probe-error' };
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* The operator-facing warning. It names the ACTION, not just the condition —
|
|
1136
|
+
* a warning the coordinator cannot act on is noise (HR-106).
|
|
1137
|
+
*
|
|
1138
|
+
* NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
|
|
1139
|
+
* both channels it writes (stderr and the `systemMessage` payload). Prefixing
|
|
1140
|
+
* here too produced `⚠ ⚠ …`.
|
|
1141
|
+
*
|
|
1142
|
+
* @param {{head: string, session_start_ref: string}} facts
|
|
1143
|
+
* @returns {string}
|
|
1144
|
+
*/
|
|
1145
|
+
function staleWorktreeWarning(facts) {
|
|
1146
|
+
const head = facts.head.slice(0, 12);
|
|
1147
|
+
const base = facts.session_start_ref.slice(0, 12);
|
|
1148
|
+
return [
|
|
1149
|
+
`${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
|
|
1150
|
+
+ `isolation: "worktree", but HEAD (${head}) has moved past this session's `
|
|
1151
|
+
+ `session-start-ref (${base}).`,
|
|
1152
|
+
' The harness bases a new agent worktree on the SESSION-START commit and offers',
|
|
1153
|
+
' no base-ref field, so the agent will silently get the OLDER code — and its test',
|
|
1154
|
+
' run is structurally red on top (check-guard-requires-parity compares against',
|
|
1155
|
+
' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
|
|
1156
|
+
' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
|
|
1157
|
+
' before trusting the agent\'s result:',
|
|
1158
|
+
' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
|
|
1159
|
+
+ '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
|
|
1160
|
+
+ 'print "STALE " substr($2,1,12) " " w}\'',
|
|
1161
|
+
].join('\n');
|
|
1162
|
+
}
|
|
1163
|
+
|
|
984
1164
|
/**
|
|
985
1165
|
* Clip a path for the deny reason without losing the discriminating tail.
|
|
986
1166
|
*
|
|
@@ -1440,8 +1620,59 @@ async function main() {
|
|
|
1440
1620
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
1441
1621
|
}
|
|
1442
1622
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1623
|
+
// #1413 — the stale-worktree-base warning, also awaited BEFORE the terminal
|
|
1624
|
+
// emit for the same process.exit() reason as the block above. It reads
|
|
1625
|
+
// `verdict` not at all: it can never turn an allow into a deny, and a git or
|
|
1626
|
+
// STATE.md failure resolves to silence rather than to a wrong accusation.
|
|
1627
|
+
//
|
|
1628
|
+
// ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
|
|
1629
|
+
// PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
|
|
1630
|
+
// channel … Invisible under exit 0" — so the first cut of this check announced
|
|
1631
|
+
// the s18 incident class to a log and to nobody. The visible channel is the
|
|
1632
|
+
// top-level `systemMessage`, i.e. `emitWarn`, which carries NO
|
|
1633
|
+
// `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
|
|
1634
|
+
// precisely what keeps warn non-blocking").
|
|
1635
|
+
//
|
|
1636
|
+
// The note is CARRIED to the single terminal emit below rather than emitted
|
|
1637
|
+
// here, because `emitWarn` calls `process.exit(0)` and never returns — warning
|
|
1638
|
+
// inline would terminate the process before a collision DENY could be emitted,
|
|
1639
|
+
// flipping a block into an allow (§ stdout discipline, the same reason
|
|
1640
|
+
// `decide()` is pure).
|
|
1641
|
+
//
|
|
1642
|
+
// `null` here means "not a worktree dispatch" and stays silent; ANY other
|
|
1643
|
+
// shape is emitted, including the `{stale: null, skipped: …}` non-measurements
|
|
1644
|
+
// (#1424). The warning still fires on `stale === true` alone — a skipped probe
|
|
1645
|
+
// accuses nobody.
|
|
1646
|
+
const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
|
|
1647
|
+
let staleNote = null;
|
|
1648
|
+
if (worktreeBase !== null) {
|
|
1649
|
+
if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
|
|
1650
|
+
try {
|
|
1651
|
+
const { emitEvent, sessionAttribution } = await import(
|
|
1652
|
+
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
1653
|
+
);
|
|
1654
|
+
await emitEvent(
|
|
1655
|
+
WORKTREE_BASE_EVENT,
|
|
1656
|
+
{ hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
|
|
1657
|
+
{ repoRoot: projectDir }
|
|
1658
|
+
);
|
|
1659
|
+
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// ONE terminal emit, with the #1413 note folded in where a visible channel is
|
|
1663
|
+
// free. On DENY the dispatch does not happen and `systemMessage` already
|
|
1664
|
+
// carries the ⛔ headline, so the note stays on the debug channel — a stale
|
|
1665
|
+
// base is moot for a dispatch that was just blocked.
|
|
1666
|
+
if (verdict.action === 'deny') {
|
|
1667
|
+
if (staleNote !== null) {
|
|
1668
|
+
try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
|
|
1669
|
+
}
|
|
1670
|
+
return emitDeny(verdict.reason, verdict.suggestion);
|
|
1671
|
+
}
|
|
1672
|
+
if (verdict.action === 'warn') {
|
|
1673
|
+
return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
|
|
1674
|
+
}
|
|
1675
|
+
if (staleNote !== null) return emitWarn(staleNote);
|
|
1445
1676
|
return emitAllow();
|
|
1446
1677
|
}
|
|
1447
1678
|
|
|
@@ -105,14 +105,14 @@
|
|
|
105
105
|
*/
|
|
106
106
|
|
|
107
107
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
108
|
-
|
|
109
|
-
if (!shouldRunHook('subagent-telemetry')) process.exit(0);
|
|
108
|
+
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
110
109
|
|
|
111
110
|
import fs from 'node:fs';
|
|
112
111
|
import path from 'node:path';
|
|
113
112
|
import { appendSubagent } from '../scripts/lib/subagents-schema.mjs';
|
|
114
113
|
import { getProjectDir } from '../scripts/lib/platform.mjs';
|
|
115
114
|
import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
115
|
+
import { readTailWindow } from '../scripts/lib/tail-window.mjs';
|
|
116
116
|
|
|
117
117
|
// ---------------------------------------------------------------------------
|
|
118
118
|
// Constants
|
|
@@ -593,25 +593,18 @@ function resolveSubagentTranscriptPath(parentTranscriptPath, agentId) {
|
|
|
593
593
|
* @returns {number|null} epoch-ms of the matching start, or null
|
|
594
594
|
*/
|
|
595
595
|
function findStartTimestampMs(filePath, agentId) {
|
|
596
|
-
let fd;
|
|
597
596
|
try {
|
|
598
597
|
if (typeof agentId !== 'string' || !agentId.trim()) return null;
|
|
599
598
|
if (!fs.existsSync(filePath)) return null;
|
|
600
599
|
|
|
601
|
-
const
|
|
602
|
-
if (size === 0) return null;
|
|
600
|
+
const window = readTailWindow(filePath, START_JOIN_TAIL_BYTES);
|
|
601
|
+
if (window.size === 0) return null;
|
|
603
602
|
|
|
604
|
-
const
|
|
605
|
-
const from = size - readLen;
|
|
606
|
-
const buf = Buffer.allocUnsafe(readLen);
|
|
607
|
-
fd = fs.openSync(filePath, 'r');
|
|
608
|
-
fs.readSync(fd, buf, 0, readLen, from);
|
|
609
|
-
|
|
610
|
-
const lines = buf.toString('utf8').split('\n');
|
|
603
|
+
const lines = window.text.split('\n');
|
|
611
604
|
// When the window does not cover the whole file, the first element is a
|
|
612
605
|
// record sliced mid-line (possibly mid-UTF-8-sequence). Drop it rather than
|
|
613
606
|
// feed a corrupt fragment to JSON.parse.
|
|
614
|
-
if (
|
|
607
|
+
if (window.cut) lines.shift();
|
|
615
608
|
|
|
616
609
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
617
610
|
const line = lines[i].trim();
|
|
@@ -631,10 +624,6 @@ function findStartTimestampMs(filePath, agentId) {
|
|
|
631
624
|
return null;
|
|
632
625
|
} catch {
|
|
633
626
|
return null;
|
|
634
|
-
} finally {
|
|
635
|
-
if (fd !== undefined) {
|
|
636
|
-
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
637
|
-
}
|
|
638
627
|
}
|
|
639
628
|
}
|
|
640
629
|
|
|
@@ -815,5 +804,12 @@ async function main() {
|
|
|
815
804
|
await appendSubagent(jsonlPath(), record);
|
|
816
805
|
}
|
|
817
806
|
|
|
818
|
-
//
|
|
819
|
-
|
|
807
|
+
// Entry guard (#1393): run only as the node script the harness execs — a bare
|
|
808
|
+
// `import()` must run no handler and must not exit the importing process.
|
|
809
|
+
if (isMainModule(import.meta.url)) {
|
|
810
|
+
// Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
|
|
811
|
+
if (!shouldRunHook('subagent-telemetry')) process.exit(0);
|
|
812
|
+
|
|
813
|
+
// Exit 0 always — informational hook must never block Claude.
|
|
814
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
815
|
+
}
|
|
@@ -41,63 +41,7 @@ import { join } from 'node:path';
|
|
|
41
41
|
// consumer repo or a test tmp-dir.
|
|
42
42
|
import { pathMatchesPattern, findScopeFile } from '../scripts/lib/hardening.mjs';
|
|
43
43
|
import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
|
|
44
|
-
|
|
45
|
-
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
46
|
-
// #801: resolve wave-scope.json via the same precedence every other reader
|
|
47
|
-
// uses (.pi/.cursor/.codex/.claude — scope-gate.mjs findScopeFile). The prior
|
|
48
|
-
// hardcoded `.orchestrator/wave-scope.json` path was DEAD — the coordinator
|
|
49
|
-
// writes wave-scope.json to the state dir (.claude/ on Claude Code; see
|
|
50
|
-
// skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
|
|
51
|
-
// returns null when no scope file exists at any precedence dir, preserving
|
|
52
|
-
// the "no active wave → exit 0" semantics below.
|
|
53
|
-
const scopePath = findScopeFile(repoRoot);
|
|
54
|
-
const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Sub-mode B — allowedPaths check
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
|
|
61
|
-
const stagedFiles = stagedOutput.split('\n').filter(Boolean);
|
|
62
|
-
|
|
63
|
-
if (scopePath) {
|
|
64
|
-
let scope;
|
|
65
|
-
try {
|
|
66
|
-
scope = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
67
|
-
} catch (err) {
|
|
68
|
-
process.stderr.write(`wave-scope-commit-guard: failed to parse wave-scope.json: ${err.message}\n`);
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
73
|
-
if (allowedPaths.length > 0) {
|
|
74
|
-
const violations = stagedFiles.filter(
|
|
75
|
-
(f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
if (violations.length > 0) {
|
|
79
|
-
process.stderr.write('✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:\n');
|
|
80
|
-
for (const v of violations) process.stderr.write(` - ${v}\n`);
|
|
81
|
-
process.stderr.write('\nThese files were likely added by lint-staged eslint --fix / prettier --write.\n');
|
|
82
|
-
process.stderr.write('To proceed:\n');
|
|
83
|
-
process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
|
|
84
|
-
process.stderr.write(' 2) git commit # retry\n');
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
//
|
|
94
|
-
// Skip entirely when no fence dir exists OR no staged files. The fence dir
|
|
95
|
-
// only appears when a wave-agent invoked `git add` while SO_WAVE_AGENT=1.
|
|
96
|
-
// Manual / coordinator commits never write a fence file, so this branch
|
|
97
|
-
// short-circuits to exit 0 for them (AC5 safe-default).
|
|
98
|
-
if (!existsSync(fenceDir) || stagedFiles.length === 0) {
|
|
99
|
-
process.exit(0);
|
|
100
|
-
}
|
|
44
|
+
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
101
45
|
|
|
102
46
|
// Resolve the "current" agent id from SO_WAVE_AGENT_ID (when set by the
|
|
103
47
|
// caller) or fall back to a PID-derived marker. The fence files themselves
|
|
@@ -105,11 +49,20 @@ if (!existsSync(fenceDir) || stagedFiles.length === 0) {
|
|
|
105
49
|
// fence entries (we cross-check against SIBLINGS, not ourselves).
|
|
106
50
|
const ownAgentId = process.env.SO_WAVE_AGENT_ID ?? null;
|
|
107
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Recorded by the writer when a staging command stages a set no path operand
|
|
54
|
+
* names (`git add -A`, `git add .`, `git add -u`). Overlaps everything.
|
|
55
|
+
* Mirrors ALL_PATHS_MARKER in hooks/pre-bash-staging-fence.mjs.
|
|
56
|
+
*/
|
|
57
|
+
const ALL_PATHS_MARKER = '*';
|
|
58
|
+
|
|
108
59
|
/**
|
|
109
60
|
* Build a regex that finds a staged path inside a `git add` command string.
|
|
110
61
|
* Word-boundary on both sides so `src/foo.ts` does not match `src/foo.ts.bak`.
|
|
111
62
|
* The path is escaped so glob metacharacters (`*`, `?`) and shell metas
|
|
112
63
|
* cannot be reinterpreted.
|
|
64
|
+
*
|
|
65
|
+
* LEGACY PATH ONLY since #1404 — see the fallback branch in findOverlaps.
|
|
113
66
|
*/
|
|
114
67
|
function pathRegex(p) {
|
|
115
68
|
const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -117,8 +70,56 @@ function pathRegex(p) {
|
|
|
117
70
|
}
|
|
118
71
|
|
|
119
72
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
73
|
+
* Normalise a path token so reader and writer compare the same spelling.
|
|
74
|
+
*
|
|
75
|
+
* DELIBERATE DUPLICATE of the identically-named function in
|
|
76
|
+
* hooks/pre-bash-staging-fence.mjs: this guard is a husky pre-commit hook
|
|
77
|
+
* outside the Claude-Code hook import set, and the two sides must agree byte
|
|
78
|
+
* for byte. Change one, change both.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} raw
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function normalizeStagedPath(raw) {
|
|
84
|
+
let p = String(raw).trim();
|
|
85
|
+
while (p.startsWith('./')) p = p.slice(2);
|
|
86
|
+
p = p.replace(/\/{2,}/g, '/');
|
|
87
|
+
while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Does a path a sibling recorded overlap one of OUR staged files? Both sides
|
|
93
|
+
* arrive already normalised. A DIRECTORY entry overlaps every file beneath it
|
|
94
|
+
* (`src` overlaps `src/foo.ts`); the marker overlaps everything.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} fencePath
|
|
97
|
+
* @param {string} ourPath
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function pathsOverlap(fencePath, ourPath) {
|
|
101
|
+
if (fencePath === ALL_PATHS_MARKER) return true;
|
|
102
|
+
if (fencePath === ourPath) return true;
|
|
103
|
+
return ourPath.startsWith(`${fencePath}/`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Walk a single sibling fence file and return the staged paths a sibling
|
|
108
|
+
* agent also recorded an intent to stage.
|
|
109
|
+
*
|
|
110
|
+
* Two entry shapes are accepted:
|
|
111
|
+
* - #1404 shape `{ paths, command_hash, timestamp }` — path LISTS compared
|
|
112
|
+
* against our staged set.
|
|
113
|
+
* - LEGACY shape `{ command, timestamp }` — a fence file written by a
|
|
114
|
+
* pre-#1404 hook version. Live sessions on this host may hold such files
|
|
115
|
+
* RIGHT NOW, and dropping them would read as "no overlap" — silently
|
|
116
|
+
* weaker than before the change. So the old raw-command regex still runs
|
|
117
|
+
* for an entry that has `command` and no `paths`.
|
|
118
|
+
* NAMED CEILING (BV-004): this fallback exists only to span the sessions
|
|
119
|
+
* running across the upgrade. REVISIT TRIGGER — remove it one minor
|
|
120
|
+
* release after 5.3.0 (i.e. in 5.4.0), by which point no process started
|
|
121
|
+
* before the upgrade can still be writing fence files. The command text it
|
|
122
|
+
* reads is never PRINTED (issue #1404) — only the fact of the match is.
|
|
122
123
|
*/
|
|
123
124
|
function findOverlaps(fenceJsonPath, ourStaged) {
|
|
124
125
|
let body;
|
|
@@ -131,61 +132,157 @@ function findOverlaps(fenceJsonPath, ourStaged) {
|
|
|
131
132
|
if (!Array.isArray(body.staged_paths)) return [];
|
|
132
133
|
if (ownAgentId && body.agent_id === ownAgentId) return []; // skip self
|
|
133
134
|
|
|
135
|
+
const siblingAgent = body.agent_id ?? '<unknown>';
|
|
134
136
|
const matches = [];
|
|
135
137
|
for (const entry of body.staged_paths) {
|
|
138
|
+
if (Array.isArray(entry?.paths)) {
|
|
139
|
+
const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
|
|
140
|
+
for (const raw of entry.paths) {
|
|
141
|
+
if (typeof raw !== 'string') continue;
|
|
142
|
+
const fencePath = normalizeStagedPath(raw);
|
|
143
|
+
for (const ours of ourStaged) {
|
|
144
|
+
if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
|
|
145
|
+
matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
const cmd = entry?.command;
|
|
137
153
|
if (typeof cmd !== 'string') continue;
|
|
138
154
|
for (const ours of ourStaged) {
|
|
139
155
|
if (pathRegex(ours).test(cmd)) {
|
|
140
|
-
matches.push({
|
|
156
|
+
matches.push({
|
|
157
|
+
ourPath: ours,
|
|
158
|
+
siblingAgent,
|
|
159
|
+
fencePath: '<legacy entry, pre-#1404>',
|
|
160
|
+
hash: '<legacy entry, pre-#1404>',
|
|
161
|
+
});
|
|
141
162
|
}
|
|
142
163
|
}
|
|
143
164
|
}
|
|
144
165
|
return matches;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Both sub-modes, in the order the pre-commit hook needs them. Every statement
|
|
170
|
+
* here used to sit at module top level, which meant a bare `import()` of this
|
|
171
|
+
* file ran `git rev-parse` / `git diff --cached` and could exit the IMPORTING
|
|
172
|
+
* process with 1 (#1393). Moved into a function so the entry guard below can
|
|
173
|
+
* gate it; the logic and its exit codes are unchanged.
|
|
174
|
+
*/
|
|
175
|
+
async function main() {
|
|
176
|
+
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
177
|
+
// #801: resolve wave-scope.json via the same precedence every other reader
|
|
178
|
+
// uses (.pi/.cursor/.codex/.claude — scope-gate.mjs findScopeFile). The prior
|
|
179
|
+
// hardcoded `.orchestrator/wave-scope.json` path was DEAD — the coordinator
|
|
180
|
+
// writes wave-scope.json to the state dir (.claude/ on Claude Code; see
|
|
181
|
+
// skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
|
|
182
|
+
// returns null when no scope file exists at any precedence dir, preserving
|
|
183
|
+
// the "no active wave → exit 0" semantics below.
|
|
184
|
+
const scopePath = findScopeFile(repoRoot);
|
|
185
|
+
const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
|
|
186
|
+
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Sub-mode B — allowedPaths check
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
|
|
192
|
+
const stagedFiles = stagedOutput.split('\n').filter(Boolean);
|
|
193
|
+
|
|
194
|
+
if (scopePath) {
|
|
195
|
+
let scope;
|
|
196
|
+
try {
|
|
197
|
+
scope = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
198
|
+
} catch (err) {
|
|
199
|
+
process.stderr.write(`wave-scope-commit-guard: failed to parse wave-scope.json: ${err.message}\n`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
204
|
+
if (allowedPaths.length > 0) {
|
|
205
|
+
const violations = stagedFiles.filter(
|
|
206
|
+
(f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (violations.length > 0) {
|
|
210
|
+
process.stderr.write('✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:\n');
|
|
211
|
+
for (const v of violations) process.stderr.write(` - ${v}\n`);
|
|
212
|
+
process.stderr.write('\nThese files were likely added by lint-staged eslint --fix / prettier --write.\n');
|
|
213
|
+
process.stderr.write('To proceed:\n');
|
|
214
|
+
process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
|
|
215
|
+
process.stderr.write(' 2) git commit # retry\n');
|
|
216
|
+
process.exit(1);
|
|
164
217
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
);
|
|
168
|
-
} catch (err) {
|
|
169
|
-
// Lock acquisition failed — emit a warning but do NOT block the commit.
|
|
170
|
-
// The race-detection layer is opportunistic; a lock-acquire timeout is
|
|
171
|
-
// strictly less severe than blocking a legitimate commit on flaky FS.
|
|
172
|
-
process.stderr.write(
|
|
173
|
-
`⚠ wave-scope-commit-guard: staging-fence lock failed — ${err?.message ?? err}\n`,
|
|
174
|
-
);
|
|
175
|
-
process.exit(0);
|
|
176
|
-
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
177
220
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
// Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
|
|
223
|
+
// -------------------------------------------------------------------------
|
|
224
|
+
//
|
|
225
|
+
// Skip entirely when no fence dir exists OR no staged files. The fence dir
|
|
226
|
+
// only appears when a wave-agent invoked `git add` while SO_WAVE_AGENT=1.
|
|
227
|
+
// Manual / coordinator commits never write a fence file, so this branch
|
|
228
|
+
// short-circuits to exit 0 for them (AC5 safe-default).
|
|
229
|
+
if (!existsSync(fenceDir) || stagedFiles.length === 0) {
|
|
230
|
+
process.exit(0);
|
|
182
231
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
232
|
+
|
|
233
|
+
let overlaps = [];
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await withStagingFenceLock(
|
|
237
|
+
repoRoot,
|
|
238
|
+
async () => {
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(fenceDir);
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
for (const name of entries) {
|
|
246
|
+
if (!name.endsWith('.json')) continue;
|
|
247
|
+
if (name.startsWith('.')) continue; // skip .commit.lock, tmp files
|
|
248
|
+
const overlapsFromFile = findOverlaps(join(fenceDir, name), stagedFiles);
|
|
249
|
+
overlaps = overlaps.concat(overlapsFromFile);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
{ timeoutMs: 5000 },
|
|
253
|
+
);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Lock acquisition failed — emit a warning but do NOT block the commit.
|
|
256
|
+
// The race-detection layer is opportunistic; a lock-acquire timeout is
|
|
257
|
+
// strictly less severe than blocking a legitimate commit on flaky FS.
|
|
258
|
+
process.stderr.write(
|
|
259
|
+
`⚠ wave-scope-commit-guard: staging-fence lock failed — ${err?.message ?? err}\n`,
|
|
260
|
+
);
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (overlaps.length > 0) {
|
|
265
|
+
process.stderr.write('✗ wave-scope-commit-guard: staging-fence: cross-agent overlap detected:\n');
|
|
266
|
+
for (const o of overlaps) {
|
|
267
|
+
process.stderr.write(
|
|
268
|
+
` - ${o.ourPath} (sibling agent ${o.siblingAgent} recorded ${o.fencePath}, command ${o.hash})\n`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
process.stderr.write('\nAnother wave-agent recorded a `git add` for one or more of your staged paths.\n');
|
|
272
|
+
process.stderr.write('To proceed:\n');
|
|
273
|
+
process.stderr.write(' 1) Coordinate with the sibling agent OR\n');
|
|
274
|
+
process.stderr.write(' 2) git restore --staged <path> # for each conflicting path, then retry\n');
|
|
275
|
+
process.stderr.write(' 3) git commit --no-verify # bypass (PSA-001/PSA-003 risk — operator opt-out)\n');
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
process.exit(0);
|
|
189
280
|
}
|
|
190
281
|
|
|
191
|
-
|
|
282
|
+
// Entry guard (#1393): run only as the node script `.husky/pre-commit` execs —
|
|
283
|
+
// a bare `import()` must run no git command and must not exit the importing
|
|
284
|
+
// process. No shouldRunHook gate here: this file is NOT registered in
|
|
285
|
+
// hooks/hooks.json, so the profile-gate never governed it.
|
|
286
|
+
if (isMainModule(import.meta.url)) {
|
|
287
|
+
await main();
|
|
288
|
+
}
|