session-orchestrator 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -3,26 +3,37 @@
|
|
|
3
3
|
* Syncs canonical rules from the plugin's rules/ library into a consumer repo's .claude/rules/.
|
|
4
4
|
*
|
|
5
5
|
* Stdlib-only, cross-platform, no Zod, no third-party parsers. ESM module.
|
|
6
|
-
* Preserves local rules
|
|
6
|
+
* Preserves local rules — files carrying the plugin provenance header in
|
|
7
|
+
* neither accepted position (see `isPluginOwnedContent`).
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
11
|
import { join, basename, dirname, resolve } from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
validateRuleContent,
|
|
15
|
+
scanVendoringLeaks,
|
|
16
|
+
isPluginOwnedContent,
|
|
17
|
+
} from './validate-vendored-rules.mjs';
|
|
13
18
|
import { isMainModule } from './is-main-module.mjs';
|
|
14
19
|
|
|
15
20
|
// The vendoring sanitizer (issue #1098) lives in validate-vendored-rules.mjs —
|
|
16
21
|
// it has that module's shape ("judge one rule file → findings") and its
|
|
17
22
|
// standalone CLI needs it too. Re-exported here so importers that predate the
|
|
18
23
|
// move (and this file's own tests) keep resolving it from rules-sync.mjs.
|
|
19
|
-
|
|
24
|
+
//
|
|
25
|
+
// isPluginOwnedContent (#1449) — the ownership predicate below — lives there
|
|
26
|
+
// for the same reason as PLUGIN_HEADER_PREFIX's copy: this module imports
|
|
27
|
+
// validate-vendored-rules.mjs, so defining it here and importing it back into
|
|
28
|
+
// the validator's provenance probe would make the two modules import each
|
|
29
|
+
// other. Re-exported so the ownership rule is reachable from the sync module.
|
|
30
|
+
export { scanVendoringLeaks, isPluginOwnedContent };
|
|
20
31
|
|
|
21
32
|
// Exported (issue #722 Epic A Wave 2) for external consumers (e.g. tests).
|
|
22
|
-
// NOT imported by validate-vendored-rules.mjs —
|
|
23
|
-
// validateRuleContent FROM
|
|
24
|
-
// importing this constant back would create a module-load cycle; it
|
|
25
|
-
// its own textually-identical copy instead.
|
|
33
|
+
// NOT imported by validate-vendored-rules.mjs — this module imports
|
|
34
|
+
// validateRuleContent FROM that file (see the pre-write gate below), so
|
|
35
|
+
// importing this constant back there would create a module-load cycle; it
|
|
36
|
+
// keeps its own textually-identical copy instead.
|
|
26
37
|
export const PLUGIN_HEADER_PREFIX = '<!-- source: session-orchestrator plugin';
|
|
27
38
|
|
|
28
39
|
/**
|
|
@@ -170,8 +181,9 @@ function escapeRegex(s) {
|
|
|
170
181
|
*
|
|
171
182
|
* When `validate` is true (the default — issue #722 Epic A Wave 2),
|
|
172
183
|
* validateRuleContent() runs as a pre-write gate on every source file before
|
|
173
|
-
* it is copied. A file with any error-severity violation (
|
|
174
|
-
* missing provenance header, unfilled placeholder) is
|
|
184
|
+
* it is copied. A file with any error-severity violation (frontmatter-not-at-top,
|
|
185
|
+
* globs-paths-mismatch, missing provenance header, unfilled placeholder) is
|
|
186
|
+
* recorded in `errors[]`
|
|
175
187
|
* and its write is skipped entirely — it is never added to
|
|
176
188
|
* written/skipped/preserved. Warn-severity violations (zero-match-globs,
|
|
177
189
|
* foreign-glob) do NOT block the write; they are collected into the
|
|
@@ -405,9 +417,12 @@ export function syncRules({
|
|
|
405
417
|
continue;
|
|
406
418
|
}
|
|
407
419
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
420
|
+
// Ownership accepts BOTH header positions (#1449): line 1 (the format
|
|
421
|
+
// every copy vendored before #1449 carries, frontmatter or not) and the
|
|
422
|
+
// first non-blank line after a line-1 frontmatter (the current format).
|
|
423
|
+
// So an old-format copy is rewritten into the new format below, never
|
|
424
|
+
// preserved; only a file with no header in either position is local.
|
|
425
|
+
if (!isPluginOwnedContent(targetContent)) {
|
|
411
426
|
preserved.push(fileName);
|
|
412
427
|
continue;
|
|
413
428
|
}
|
|
@@ -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,36 +657,47 @@ 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)
|
|
604
695
|
? plannedFilesRaw
|
|
605
696
|
: 0;
|
|
606
697
|
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// edited often enough that a pinned number goes stale, as this very comment did.
|
|
698
|
+
// Division-by-zero guard: a baseline that froze zero planned files (no declared
|
|
699
|
+
// file scope, or a non-numeric count coerced to 0 above) would otherwise yield
|
|
700
|
+
// Infinity or NaN; `max(plannedFiles, 1)` keeps the ratio finite and comparable.
|
|
611
701
|
const filesRatio = round2(actualFiles / Math.max(plannedFiles, 1));
|
|
612
702
|
const breached = filesRatio >= threshold;
|
|
613
703
|
|
|
@@ -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'));
|
|
@@ -16,6 +16,9 @@ import path from 'node:path';
|
|
|
16
16
|
|
|
17
17
|
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
18
18
|
|
|
19
|
+
/** The harness state dirs a `wave-scope.json` may live in, highest precedence first. */
|
|
20
|
+
const SCOPE_FILE_DIRS = Object.freeze(['.pi', '.cursor', '.codex', '.claude']);
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* Find the wave-scope.json file for the given project root.
|
|
21
24
|
*
|
|
@@ -28,11 +31,12 @@ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './comma
|
|
|
28
31
|
* Returns the absolute path string, or null if none exist.
|
|
29
32
|
* Sync (uses fs.existsSync).
|
|
30
33
|
*
|
|
34
|
+
* @deprecated Use findOwnScopeFile because first-file selection ignores manifest ownership and can select a peer session's manifest.
|
|
31
35
|
* @param {string} projectRoot — absolute path to project root
|
|
32
36
|
* @returns {string|null}
|
|
33
37
|
*/
|
|
34
38
|
export function findScopeFile(projectRoot) {
|
|
35
|
-
for (const dir of
|
|
39
|
+
for (const dir of SCOPE_FILE_DIRS) {
|
|
36
40
|
const candidate = path.join(projectRoot, dir, 'wave-scope.json');
|
|
37
41
|
if (existsSync(candidate)) {
|
|
38
42
|
return candidate;
|
|
@@ -41,6 +45,67 @@ export function findScopeFile(projectRoot) {
|
|
|
41
45
|
return null;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The manifest that governs THIS session: walk the {@link findScopeFile}
|
|
50
|
+
* precedence (`.pi` > `.cursor` > `.codex` > `.claude`) and return the first
|
|
51
|
+
* candidate that is NOT provably bound to another session (#1504 point 6).
|
|
52
|
+
*
|
|
53
|
+
* `findScopeFile` stops at the first file that EXISTS. When that file is a
|
|
54
|
+
* peer's — another harness session's manifest in the same working copy — every
|
|
55
|
+
* guard that stands down on a foreign manifest stood down, and this session's
|
|
56
|
+
* own `.claude/wave-scope.json` one rung lower was never read: a peer's
|
|
57
|
+
* manifest switched this session's scope gates off.
|
|
58
|
+
*
|
|
59
|
+
* Disposition per candidate, decided INSIDE the loop (an identity check after
|
|
60
|
+
* the loop makes the first readable candidate the veto-holder —
|
|
61
|
+
* `.claude/rules/identity-and-locks.md`):
|
|
62
|
+
* - `'own'` or `'unknown'` → return it. `'unknown'` covers an unbound/legacy
|
|
63
|
+
* manifest, a corrupt or unreadable one (judged as `{}`), and an empty
|
|
64
|
+
* `ownIds` (a harness that supplies no session id). Each means ENFORCE
|
|
65
|
+
* under the binding contract, so each stays the decider exactly as under
|
|
66
|
+
* `findScopeFile` — a truncated manifest cannot skip itself.
|
|
67
|
+
* - `'foreign'` → remember the first one and `continue`.
|
|
68
|
+
*
|
|
69
|
+
* When EVERY existing candidate is foreign, `path` is null and `foreignPath`
|
|
70
|
+
* names the first of them, so the caller reaches its existing foreign
|
|
71
|
+
* disposition (allow + `orchestrator.scope.foreign_session_ignored`) unchanged.
|
|
72
|
+
*
|
|
73
|
+
* `classify` is injected, not imported: importing the identity module here
|
|
74
|
+
* would add it to the import closure of every hook that reaches this file
|
|
75
|
+
* through the `hardening.mjs` barrel, while only the guards that stand down on
|
|
76
|
+
* a foreign manifest need it — and each of them already binds it. Pass
|
|
77
|
+
* `classifyManifestSession` from `session-identity/own-session.mjs`.
|
|
78
|
+
*
|
|
79
|
+
* COST: identical to `findScopeFile` when no manifest exists (≤4 `existsSync`).
|
|
80
|
+
* With one, one extra sync read + parse of that small file. The caller still
|
|
81
|
+
* reads the returned file itself and classifies THAT read, so a rebind between
|
|
82
|
+
* the two reads is judged at the same decision point as before.
|
|
83
|
+
*
|
|
84
|
+
* Sync. Never throws (unless `classify` does).
|
|
85
|
+
*
|
|
86
|
+
* @param {string} projectRoot absolute path to the project root
|
|
87
|
+
* @param {Set<string>} ownIds process-local own ids (`readProcessLocalSessionIds`)
|
|
88
|
+
* @param {(scope: object, ownIds: Set<string>) => { verdict: string }} classify
|
|
89
|
+
* @returns {{ path: string|null, foreignPath: string|null }}
|
|
90
|
+
*/
|
|
91
|
+
export function findOwnScopeFile(projectRoot, ownIds, classify) {
|
|
92
|
+
let foreignPath = null;
|
|
93
|
+
for (const dir of SCOPE_FILE_DIRS) {
|
|
94
|
+
const candidate = path.join(projectRoot, dir, 'wave-scope.json');
|
|
95
|
+
if (!existsSync(candidate)) continue;
|
|
96
|
+
let scope = {};
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
99
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) scope = parsed;
|
|
100
|
+
} catch {
|
|
101
|
+
// unreadable / corrupt → `{}` → no binding → 'unknown' → this candidate decides
|
|
102
|
+
}
|
|
103
|
+
if (classify(scope, ownIds).verdict !== 'foreign') return { path: candidate, foreignPath };
|
|
104
|
+
foreignPath ??= candidate;
|
|
105
|
+
}
|
|
106
|
+
return { path: null, foreignPath };
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/**
|
|
45
110
|
* Read the enforcement level from a scope file.
|
|
46
111
|
* Defaults to "strict" (fail-closed) on parse error or missing field.
|
|
@@ -190,7 +255,8 @@ function literalScopePrefix(entry) {
|
|
|
190
255
|
* verbatim presence + literal-prefix coverage; it does not prove that e.g.
|
|
191
256
|
* `src/**\/*.ts` ⊆ `src/**\/*.js` is false. The concrete-path branch above is
|
|
192
257
|
* exact and carries the incident-relevant load (the union the coordinator
|
|
193
|
-
* actually writes is
|
|
258
|
+
* actually writes is the deduplicated agent scopes in canonical spelling, see
|
|
259
|
+
* {@link unionFileScopes}). Erring toward
|
|
194
260
|
* over-approximating coverage keeps a legitimate union from being rejected on a
|
|
195
261
|
* glob technicality rather than pretending to a precision this matcher lacks.
|
|
196
262
|
*
|
|
@@ -211,16 +277,21 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
|
|
|
211
277
|
const missing = [];
|
|
212
278
|
for (const entry of fileScope) {
|
|
213
279
|
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
280
|
+
// The AGENT side is compared in canonical spelling — the spelling the union
|
|
281
|
+
// emits and the write path Gate 7 matches. `allowedPaths` stays as written:
|
|
282
|
+
// a hand-written `./x` there is a pattern Gate 7 cannot match, and spelling
|
|
283
|
+
// it away here would report coverage the hook then denies.
|
|
284
|
+
const spelled = scopeEntrySpelling(entry);
|
|
214
285
|
let covered;
|
|
215
|
-
if (isGlobScopeEntry(
|
|
286
|
+
if (isGlobScopeEntry(spelled)) {
|
|
216
287
|
// GLOB entry: verbatim presence OR literal-prefix coverage.
|
|
217
|
-
const prefix = literalScopePrefix(
|
|
218
|
-
covered = allowedPaths.some((p) => p ===
|
|
288
|
+
const prefix = literalScopePrefix(spelled);
|
|
289
|
+
covered = allowedPaths.some((p) => p === spelled || pathMatchesPattern(prefix, p));
|
|
219
290
|
} else {
|
|
220
291
|
// CONCRETE entry: must match ≥1 allowedPaths pattern.
|
|
221
|
-
covered = allowedPaths.some((p) => pathMatchesPattern(
|
|
292
|
+
covered = allowedPaths.some((p) => pathMatchesPattern(spelled, p));
|
|
222
293
|
}
|
|
223
|
-
if (!covered) missing.push(entry);
|
|
294
|
+
if (!covered) missing.push(entry); // as declared, so the plan entry is findable
|
|
224
295
|
}
|
|
225
296
|
return { ok: missing.length === 0, missing };
|
|
226
297
|
}
|
|
@@ -329,13 +400,16 @@ export function testSiblingExpansionApplies(opts = {}) {
|
|
|
329
400
|
*
|
|
330
401
|
* ## Why a GLOB and not a computed path
|
|
331
402
|
* The emitted sibling is `tests/**\/{basename}*.test.mjs`, never a concrete
|
|
332
|
-
* path
|
|
333
|
-
* a naive 1:1 mirror (`scripts/lib/X.mjs →
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
403
|
+
* path: a same-basename test ANYWHERE under `tests/` resolves for far more
|
|
404
|
+
* production files than a naive 1:1 mirror (`scripts/lib/X.mjs →
|
|
405
|
+
* tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
|
|
406
|
+
* measuring command and the SHA — live in ONE place:
|
|
407
|
+
* skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
|
|
408
|
+
* Expansion (#970). Cite and re-measure them there, never restate them here: an
|
|
409
|
+
* undated copy drifted to a second, contradicting set of numbers (#1026.5,
|
|
410
|
+
* PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
|
|
411
|
+
* to files that may not exist. A computed concrete path is wrong more often AND
|
|
412
|
+
* still denies the real test, which is the original bug wearing a new face.
|
|
339
413
|
*
|
|
340
414
|
* ## Required behaviours (each is a nameable regression)
|
|
341
415
|
* 1. `[]` → `[]` STRUCTURALLY. Discovery waves use an empty scope as a
|
|
@@ -403,8 +477,11 @@ export function testSiblingsFor(entries, opts = {}) {
|
|
|
403
477
|
: DEFAULT_TEST_PATH_PATTERNS;
|
|
404
478
|
const out = [];
|
|
405
479
|
const seen = new Set();
|
|
406
|
-
for (const
|
|
407
|
-
if (typeof
|
|
480
|
+
for (const declared of Array.isArray(entries) ? entries : []) {
|
|
481
|
+
if (typeof declared !== 'string' || declared.length === 0) continue;
|
|
482
|
+
// Canonical spelling, as the union carries it: a verbatim `./tests/x.mjs`
|
|
483
|
+
// misses `tests/**` and would be mirrored as if it were production code.
|
|
484
|
+
const entry = scopeEntrySpelling(declared);
|
|
408
485
|
if (path.isAbsolute(entry)) continue; // behaviour 3
|
|
409
486
|
if (isGlobScopeEntry(entry)) continue; // no single basename to mirror
|
|
410
487
|
if (isTestPathEntry(entry, testPatterns)) continue; // behaviours 2 + 4
|
|
@@ -940,6 +1017,53 @@ export function isPeerRecordId(id) {
|
|
|
940
1017
|
return typeof id === 'string' && id.startsWith(PEER_RECORD_PREFIX);
|
|
941
1018
|
}
|
|
942
1019
|
|
|
1020
|
+
/**
|
|
1021
|
+
* Record id of the COORDINATOR's own planned direct edits (#1020): one
|
|
1022
|
+
* `{id: 'coordinator', files}` record in a wave's aggregate sidecar, and
|
|
1023
|
+
* `<state-dir>/filescopes/wave-<N>/coordinator.json` in the per-agent shape.
|
|
1024
|
+
* Exactly lowercase — `materialize-wave-scope.mjs` rejects any other casing.
|
|
1025
|
+
*
|
|
1026
|
+
* The coordinator is a declarant but never a DISPATCH: no `FILE-SCOPE` block is
|
|
1027
|
+
* injected for it, no digest is echoed back, no agent is counted for it. Every
|
|
1028
|
+
* consumer that joins scope files against dispatches must skip it, or it reports
|
|
1029
|
+
* the coordinator's own scope as an omitted injection (#1493.2). SSOT beside
|
|
1030
|
+
* {@link PEER_RECORD_PREFIX} for the same reason that one lives here.
|
|
1031
|
+
*/
|
|
1032
|
+
export const COORDINATOR_RECORD_ID = 'coordinator';
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Is this record id the coordinator's (see {@link COORDINATOR_RECORD_ID})?
|
|
1036
|
+
* Exact match; a non-string id is not the coordinator.
|
|
1037
|
+
*
|
|
1038
|
+
* @param {unknown} id
|
|
1039
|
+
* @returns {boolean}
|
|
1040
|
+
*/
|
|
1041
|
+
export function isCoordinatorRecordId(id) {
|
|
1042
|
+
return id === COORDINATOR_RECORD_ID;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Is this repo-relative path inside a wave's per-agent scope declarations,
|
|
1047
|
+
* `<state-dir>/filescopes/` (shape (a) and the aggregate sidecar, #1020)?
|
|
1048
|
+
*
|
|
1049
|
+
* Those files are coordinator CONTROL STATE like `wave-scope.json` itself,
|
|
1050
|
+
* written by `materialize-wave-scope.mjs` once per wave — never a wave
|
|
1051
|
+
* territory, so a write there is not a scope violation (#1027 Pkt 5). The
|
|
1052
|
+
* caller passes the manifest's own repo-relative path, so the state dir is the
|
|
1053
|
+
* one THIS manifest lives in, never a guessed `.claude/`.
|
|
1054
|
+
*
|
|
1055
|
+
* @param {string} relPath repo-relative, forward slashes
|
|
1056
|
+
* @param {string} scopeRelPath repo-relative path of the active `wave-scope.json`
|
|
1057
|
+
* @returns {boolean}
|
|
1058
|
+
*/
|
|
1059
|
+
export function isScopeDeclarationPath(relPath, scopeRelPath) {
|
|
1060
|
+
if (typeof relPath !== 'string' || typeof scopeRelPath !== 'string') return false;
|
|
1061
|
+
const scopeRel = scopeRelPath.replace(/\\/g, '/');
|
|
1062
|
+
const cut = scopeRel.lastIndexOf('/');
|
|
1063
|
+
if (cut <= 0) return false;
|
|
1064
|
+
return relPath.startsWith(`${scopeRel.slice(0, cut)}/filescopes/`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
943
1067
|
/**
|
|
944
1068
|
* Merge many agents' declared file scopes into ONE deduplicated, order-stable
|
|
945
1069
|
* list — the mechanical form of "allowedPaths is the UNION of all agent file
|
|
@@ -965,6 +1089,14 @@ export function isPeerRecordId(id) {
|
|
|
965
1089
|
* non-array / non-object members and non-string, empty entries are skipped.
|
|
966
1090
|
* Pure, sync, no I/O — hook-safe per the module header.
|
|
967
1091
|
*
|
|
1092
|
+
* ENTRIES ARE EMITTED IN CANONICAL SPELLING ({@link scopeEntrySpelling}) and
|
|
1093
|
+
* deduplicated on it. Gate 7 matches the repo-relative WRITE path against each
|
|
1094
|
+
* entry, so a verbatim `./scripts/lib/foo.mjs` was a pattern no write could
|
|
1095
|
+
* ever match — the declaring agent's own write was denied — and kept a second
|
|
1096
|
+
* spelling of `scripts/lib/foo.mjs` beside it. The agent-side checks
|
|
1097
|
+
* ({@link assertFileScopeSubset}, {@link testSiblingsFor}) compare the same
|
|
1098
|
+
* spelling, so a per-agent file that keeps the plan's `./x` still passes.
|
|
1099
|
+
*
|
|
968
1100
|
* PEER RECORDS ARE EXCLUDED (#1195 follow-through). A record whose id starts
|
|
969
1101
|
* with `peer-session-` declares a territory NO agent of this wave may write —
|
|
970
1102
|
* it exists so a peer's paths take part in the DISJOINTNESS check and so
|
|
@@ -995,9 +1127,10 @@ export function unionFileScopes(scopes) {
|
|
|
995
1127
|
if (files === null) continue;
|
|
996
1128
|
for (const entry of files) {
|
|
997
1129
|
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
998
|
-
|
|
999
|
-
seen.
|
|
1000
|
-
|
|
1130
|
+
const spelled = scopeEntrySpelling(entry);
|
|
1131
|
+
if (seen.has(spelled)) continue;
|
|
1132
|
+
seen.add(spelled);
|
|
1133
|
+
out.push(spelled);
|
|
1001
1134
|
}
|
|
1002
1135
|
}
|
|
1003
1136
|
return out;
|
|
@@ -1122,8 +1255,11 @@ function globsDisagreeOnLiteralSegment(x, y) {
|
|
|
1122
1255
|
* NEGATIVE — the whole point of this function is that an unreviewed scope is
|
|
1123
1256
|
* exactly the one that collides.
|
|
1124
1257
|
*
|
|
1258
|
+
* `files` holds each entry's comparison SPELLING ({@link scopeEntrySpelling});
|
|
1259
|
+
* `declared` holds the entry as written, index-aligned, for the evidence report.
|
|
1260
|
+
*
|
|
1125
1261
|
* @param {Array<{id?: string, files?: string[]}>} agentScopes
|
|
1126
|
-
* @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
|
|
1262
|
+
* @returns {Array<{id: string, declaredId: string|null, files: string[], declared: string[]}>}
|
|
1127
1263
|
*/
|
|
1128
1264
|
function normalizeAgentScopes(agentScopes) {
|
|
1129
1265
|
const out = [];
|
|
@@ -1131,14 +1267,43 @@ function normalizeAgentScopes(agentScopes) {
|
|
|
1131
1267
|
const raw = agentScopes[i];
|
|
1132
1268
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
1133
1269
|
const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
|
|
1134
|
-
const
|
|
1270
|
+
const declared = Array.isArray(raw.files)
|
|
1135
1271
|
? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
|
|
1136
1272
|
: [];
|
|
1137
|
-
|
|
1273
|
+
const files = declared.map(scopeEntrySpelling);
|
|
1274
|
+
out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files, declared });
|
|
1138
1275
|
}
|
|
1139
1276
|
return out;
|
|
1140
1277
|
}
|
|
1141
1278
|
|
|
1279
|
+
/**
|
|
1280
|
+
* One scope entry's canonical SPELLING — THE spelling rule of this module.
|
|
1281
|
+
* Used by the collision comparison (#1026.3), by {@link unionFileScopes} (whose
|
|
1282
|
+
* output IS `allowedPaths`, so Gate 7 sees this spelling), and on the agent side
|
|
1283
|
+
* of {@link assertFileScopeSubset} / {@link testSiblingsFor}, so a per-agent file
|
|
1284
|
+
* that keeps the plan's declaration verbatim still agrees with the union.
|
|
1285
|
+
* Equal-meaning spellings compared unequal before: `./scripts/lib/foo.mjs` vs
|
|
1286
|
+
* `scripts/lib/foo.mjs` was reported disjoint (measured 2026-10-03 @ 8292050e).
|
|
1287
|
+
* It changes no grant's MEANING — only how one is spelled.
|
|
1288
|
+
*
|
|
1289
|
+
* Collapses runs of `/` and drops `.` segments (`./a`, `a/./b`). A leading `/`
|
|
1290
|
+
* (absolute entry) and a trailing `/` survive: the trailing slash is meaning,
|
|
1291
|
+
* not spelling — `scripts/lib/` is a prefix grant, while `scripts/lib` grants
|
|
1292
|
+
* only the literal path `scripts/lib` (`pathMatchesPattern('scripts/lib/foo.mjs',
|
|
1293
|
+
* 'scripts/lib') === false`), so those two stay disjoint.
|
|
1294
|
+
*
|
|
1295
|
+
* NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
|
|
1296
|
+
* when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
|
|
1297
|
+
*
|
|
1298
|
+
* @param {string} entry - a non-empty scope entry
|
|
1299
|
+
* @returns {string}
|
|
1300
|
+
*/
|
|
1301
|
+
function scopeEntrySpelling(entry) {
|
|
1302
|
+
const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
|
|
1303
|
+
// `./` alone would spell as '' — keep the entry rather than invent a meaning.
|
|
1304
|
+
return spelled.length > 0 ? spelled : entry;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1142
1307
|
/**
|
|
1143
1308
|
* Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
|
|
1144
1309
|
* Returns the collision `kind`, or `null` when the two entries are disjoint.
|
|
@@ -1316,17 +1481,19 @@ export function findScopeCollisions(agentScopes, opts = {}) {
|
|
|
1316
1481
|
const b = agents[j];
|
|
1317
1482
|
if (a.id === b.id) continue; // duplicate-id record: reported separately
|
|
1318
1483
|
const buckets = new Map();
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1484
|
+
// Classify on the comparison spelling; report the entry AS DECLARED, so
|
|
1485
|
+
// the operator can find it in the plan (#1026.3).
|
|
1486
|
+
for (let xi = 0; xi < a.files.length; xi++) {
|
|
1487
|
+
for (let yi = 0; yi < b.files.length; yi++) {
|
|
1488
|
+
const kind = classifyEntryCollision(a.files[xi], b.files[yi], expand);
|
|
1322
1489
|
if (kind === null) continue;
|
|
1323
1490
|
let evidence = buckets.get(kind);
|
|
1324
1491
|
if (evidence === undefined) {
|
|
1325
1492
|
evidence = new Set();
|
|
1326
1493
|
buckets.set(kind, evidence);
|
|
1327
1494
|
}
|
|
1328
|
-
evidence.add(
|
|
1329
|
-
evidence.add(
|
|
1495
|
+
evidence.add(a.declared[xi]);
|
|
1496
|
+
evidence.add(b.declared[yi]);
|
|
1330
1497
|
}
|
|
1331
1498
|
}
|
|
1332
1499
|
for (const kind of KIND_ORDER) {
|