session-orchestrator 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +28 -13
- package/docs/scope-collision-guard.md +5 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +76 -10
- package/package.json +8 -3
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +86 -13
- package/scripts/validate-wave-scope.mjs +39 -4
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.6.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "session-orchestrator",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.0",
|
|
5
5
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bernhard Goetzendorfer",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0+codex.20261003103620",
|
|
4
4
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"session",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
4
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bernhard Goetzendorfer"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [5.6.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`validate-wave-scope --assert-disjoint --no-manifest`** (#1510 f): runs the disjointness check on the per-wave sidecar alone, without a manifest on stdin, so `/plan` no longer fabricates a dummy manifest. Without the flag an empty stdin stays an error (fail-closed); combining it with `--union`, `--assert-subset`, `--expand-test-siblings` or a positional manifest is rejected.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Wave git facts follow the session root** (`hooks/post-tool-batch-wave-signal.mjs`, `hooks/on-stop.mjs`, `hooks/pre-bash-memory-propose-audit.mjs`, #1511 a–c): in a session that entered a worktree, the wave's start SHA, changed-file count, HEAD, the stop record's branch/commit and the audit record's `cwd` now describe the tree the session works in. `current-session.json`, `session.lock` and the heartbeat stay at the launch dir, where `on-session-start` writes them.
|
|
19
|
+
- **The commit guard also judges the session-root manifest** (`hooks/wave-scope-commit-guard.mjs`, #1511 d): a session launched in a repo subdirectory has its own manifest checked in addition to the repo-root manifest, which is judged exactly as before; either one blocking blocks the commit. Staged paths outside the session root are violations without pattern matching, and the path is resolved with `realpathSync.native`.
|
|
20
|
+
- **One scope-entry spelling rule** (`scripts/lib/scope-gate.mjs` `scopeEntrySpelling`, now exported, #1510 g): `pre-task-scope-disjoint` drops its drifted copy (`scripts/././lib` stayed unpromoted there, so a sibling claim compared as disjoint).
|
|
21
|
+
- **Close-backfill reads ledger gaps inside the session's own window** (`scripts/lib/session-close-backfill.mjs`, #1512): a recoverable `unindexed-archive` gap overlapping the session defers the candidate; a pruned `missing-archive` gap writes the record and lists `session_type`/`session_profile` (and `completed_at` when the gap runs past the last event) in `_backfill_incomplete_fields`. Gaps outside the window, or without bounds, no longer matter.
|
|
22
|
+
- **Website, README and npm description** lead with the problem the plugin solves and group the features by benefit (site EN/DE, guide, `llms.txt`, `llms-full.txt`).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A sidecar changed under a standing manifest no longer downgrades violations** (`hooks/post-bash-write-verify.mjs`, #1504 point 5): the sidecar hash is bound into the hook's trusted snapshot; on a mismatch `peer-session-*` records are ignored and one notice per mismatching hash is shown. A mid-wave Peer-Scope-Union must re-run the full scope sequence including the skeleton rewrite (`skills/_shared/parallel-aware-auq.md`).
|
|
27
|
+
- **Destructive-command guard counts on the site** read 11 blocking of 15 rules; the policy has 12 blocking and 4 warning rules of 16.
|
|
28
|
+
- **`.phase-result` on the site** loses its coloured side stripe (#1357, first half).
|
|
29
|
+
|
|
10
30
|
## [5.5.0] - 2026-10-03
|
|
11
31
|
|
|
12
32
|
### Added
|
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# Session Orchestrator
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](CHANGELOG.md)
|
|
5
5
|
[](https://www.npmjs.com/package/session-orchestrator)
|
|
6
6
|
|
|
7
7
|
**Give your agents a working rhythm.**
|
|
8
8
|
|
|
9
|
+
Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. On their own, coding agents drift from the plan, overwrite each other's files and call work done before it is checked; Session Orchestrator closes those gaps.
|
|
10
|
+
|
|
9
11
|
You type three commands:
|
|
10
12
|
|
|
11
13
|
- **`/session`** reads your repository, your open issues and the last session, proposes what to work on, and waits for your correction.
|
|
12
|
-
- **`/go`** runs the agreed work in waves of parallel agents
|
|
14
|
+
- **`/go`** runs the agreed work in waves of parallel agents. After each implementation wave your tests and typecheck run, and failures become fix tasks for the next wave. The full test, typecheck and lint gate runs before `/close` commits.
|
|
13
15
|
- **`/close`** checks every planned item against what actually happened, commits, and files the rest as issues for next time.
|
|
14
16
|
|
|
15
17
|
Session Orchestrator is a free, MIT-licensed workflow plugin for **Claude Code, Codex CLI, Cursor IDE, or [Pi](docs/pi-setup.md)**. It runs on your machine and writes plain text into your repository. No account, no server, nothing to sign up for.
|
|
@@ -59,7 +61,7 @@ persistence: true
|
|
|
59
61
|
enforcement: warn
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
The first three are the commands
|
|
64
|
+
The first three are the commands the quality gate runs: tests and typecheck after each implementation wave, all three after the Quality wave and before `/close` commits — use whatever your project actually uses. Everything else is opt-in: [full template](docs/session-config-template.md) · [every key, its type and default](docs/session-config-reference.md).
|
|
63
65
|
|
|
64
66
|
**3. Run the loop.**
|
|
65
67
|
|
|
@@ -73,6 +75,20 @@ On Codex the same three are `$session-orchestrator:session feature`, `$session-o
|
|
|
73
75
|
|
|
74
76
|
In headless Claude Code (`claude -p`), `/session` and `/plan` are reserved terminal-only built-in names and the bare form is refused; use `/session-orchestrator:session` and `/session-orchestrator:plan` there. Every other command keeps its bare form.
|
|
75
77
|
|
|
78
|
+
## What you get
|
|
79
|
+
|
|
80
|
+
Each item: what it does for you, then how it works.
|
|
81
|
+
|
|
82
|
+
| You get | How |
|
|
83
|
+
|---|---|
|
|
84
|
+
| **Know what to work on.** Every session starts from the real state of your project, not from a prompt you rewrite. | `/session` reads git, open issues, recent commits and the last session record, recommends one scope and waits for your correction. `/plan`, `/brainstorm` and `/grill` help while an idea is still vague. |
|
|
85
|
+
| **Errors stop at the next check.** A broken test is found right after the work that caused it, not after more work is built on top. | `/go` checks each wave with your tests and typecheck and turns failures into fix tasks; lint and the full suite run before the commit. `/close` checks every planned item against what happened. `/debug` looks for the root cause before a fix. |
|
|
86
|
+
| **Agents keep to their files.** Parallel agents do not overwrite each other, and destructive commands do not slip through. | Each agent gets a declared list of files it may change; overlapping lists are rejected before dispatch. A guard blocks 12 destructive command patterns (hard reset, `rm -rf`, force-push, `DROP TABLE`, …) and warns on 4. Hooks on Claude Code, bridges on Cursor and Pi, instructions only on Codex. |
|
|
87
|
+
| **Pick up where you stopped.** A crash or a second session in the same folder does not cost you the work. | `STATE.md` (a progress file in your repo) records each finished wave, and the next `/session` offers to resume. A session lock with a heartbeat keeps two sessions apart in one checkout. `/close` files what is left as GitLab or GitHub issues. |
|
|
88
|
+
| **It improves with your sessions.** Patterns from your own work become rules, once you approve them. | Every session appends a record. `/evolve` proposes learnings with a confidence score, `/reconcile` drafts rules from them; you approve or delete each one. Optional mirror into a Markdown notes vault. |
|
|
89
|
+
| **Audit, test and review on demand.** Find problems you did not ask about, and show the app works. | A discovery scan at `/close` turns confirmed findings into issues. `/test` drives web and macOS flows end to end, `/ux-grill` audits a running web app, `/test-audit` removes tests that catch nothing. `/repo-audit` and `/harness-audit` score a repository against a checklist, and `/persona-panel` has several reviewer personas read the same output in parallel. |
|
|
90
|
+
| **Longer runs, more repositories.** | `/autopilot` chains sessions and stops on any of 10 kill-switches. `/dispatcher` recommends the next free repository and claims it once you confirm, and `/portfolio` summarises issues, MRs and CI across the repositories registered in your notes vault. |
|
|
91
|
+
|
|
76
92
|
## How it works
|
|
77
93
|
|
|
78
94
|
When you type `/session feature`:
|
|
@@ -80,7 +96,7 @@ When you type `/session feature`:
|
|
|
80
96
|
1. **It reads the project.** Git state, open issues, recent commits, documentation, host resources and the records of previous sessions become a Session Overview with one recommendation.
|
|
81
97
|
2. **You correct the scope.** Nothing is implemented until you agree to the plan.
|
|
82
98
|
3. **The work is split into waves.** The session type decides how many: housekeeping 1, feature 3, deep 5, the ultradeep profile 7. Each wave gets a purpose, a list of file paths it may write to, and a result that can be checked.
|
|
83
|
-
4. **`/go` runs it.** Agents whose file scopes do not overlap run at the same time on Claude Code and Codex; Cursor and Pi run them one after another. After each wave
|
|
99
|
+
4. **`/go` runs it.** Agents whose file scopes do not overlap run at the same time on Claude Code and Codex; Cursor and Pi run them one after another. After each implementation wave your tests and typecheck run, and failures become fix tasks for the next wave. The full test, typecheck and lint gate runs after the Quality wave.
|
|
84
100
|
5. **`/close` checks and records.** It compares the plan against what happened, runs the full quality gate, commits file by file, and opens issues for whatever was not finished.
|
|
85
101
|
|
|
86
102
|
**What it writes into your repository**, and nothing else — all of it plain text, all of it local:
|
|
@@ -100,7 +116,7 @@ The plugin is **51 skills, 27 slash commands, 14 typed subagents and 29 hook fil
|
|
|
100
116
|
## Why it is built this way
|
|
101
117
|
|
|
102
118
|
- **The wave order is deliberate.** Discovery runs first so every implementer starts from the same picture of the code. Impl-Core runs before Impl-Polish so the structure exists before anything integrates against it. The Quality wave simplifies generated code *before* tests are written — write the tests first and they assert whatever the model produced, so changing it later means rewriting the tests too.
|
|
103
|
-
- **Checks run
|
|
119
|
+
- **Checks run after each implementation wave, not only at the end.** A mistake caught after wave 2 costs one wave. The same mistake found at `/close` has already been copied into every wave after it. Findings below the configured confidence threshold are not shown to you.
|
|
104
120
|
- **A crash does not lose the session.** `STATE.md` records which wave finished and what deviated from the plan. The next `/session` offers to continue from the last completed wave.
|
|
105
121
|
- **Two sessions in one working copy is treated as a real risk.** Two people, or two of your own sessions, in the same checkout share one git index, one filesystem and one `STATE.md`, and neither can see the other's uncommitted work. A heartbeat session lock, per-agent file-scope manifests, and the PSA rules in [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) exist for exactly that case.
|
|
106
122
|
- **Guards run where the harness supports them, and the table below says where it does not.** A destructive-command policy — 12 rules block, 4 warn — and file-scope enforcement run as real hooks on Claude Code, as bridges on Cursor and Pi, and as instructions only on Codex. Details: [`docs/components.md`](docs/components.md#other-surfaces).
|
|
@@ -123,16 +139,15 @@ How this compares to other orchestrators, with measured results kept separate fr
|
|
|
123
139
|
|
|
124
140
|
All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
|
|
125
141
|
|
|
126
|
-
## Recent highlights (v5.
|
|
142
|
+
## Recent highlights (v5.6.0)
|
|
127
143
|
|
|
128
|
-
Highlights of the v5.
|
|
144
|
+
Highlights of the v5.6.0 line:
|
|
129
145
|
|
|
130
|
-
- **
|
|
131
|
-
- **
|
|
132
|
-
- **
|
|
133
|
-
- **
|
|
134
|
-
- **
|
|
135
|
-
- **`npm run update:local` updates every local install**, and Claude Code installs the plugin's dependencies again (the `overrides` block it refused is gone). The `/session-orchestrator:navigator` skill left the package; the fleet file contract stays (#1491).
|
|
146
|
+
- **Clearer about what you get.** The website, this README and the npm page now open with the problem the plugin solves and list the features by benefit: what each part does for you, then how it works.
|
|
147
|
+
- **Sessions in a worktree or a subdirectory report their own tree.** A wave's start commit, changed files, HEAD and the stop record's branch now describe the tree the session works in, and the commit guard also checks the manifest of a session launched in a subdirectory, never less strictly than before (#1511).
|
|
148
|
+
- **One rule for how scope paths are spelled.** The overlap check before dispatch and the scope tools now compare paths the same way, and `validate-wave-scope --assert-disjoint --no-manifest` checks a plan's file lists without a dummy manifest (#1510).
|
|
149
|
+
- **A changed scope sidecar can no longer hide a violation.** If the per-wave sidecar changes under a standing manifest, peer records stop counting and you get one notice (#1504).
|
|
150
|
+
- **Close-backfill reads ledger gaps only inside the session's own time window**, so one pruned archive no longer holds back every later session (#1512).
|
|
136
151
|
|
|
137
152
|
Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
|
|
138
153
|
|
|
@@ -35,7 +35,9 @@ The same ordering argument applies one level up: step 2 runs on the **declared**
|
|
|
35
35
|
|
|
36
36
|
### 2.2 Why the scope files are not temp files
|
|
37
37
|
|
|
38
|
-
Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory, so they never enter `allowedPaths` — that would grant agents write access to the deconfliction record. Until #1027 Pkt 5 writing them tripped `bash-write-verify` once per wave rollover; its `computeReport` now drops `<state-dir>/filescopes/**` from the violation set beside the manifest itself (`isScopeDeclarationPath()` in `scope-gate.mjs`, keyed on the ACTIVE manifest's state dir, so another harness dir's `filescopes/` still reports). The cost, named: a Bash rewrite of a scope file after dispatch is no longer reported there. For the per-agent files `filescopes/wave-<N>/<agent-id>.json` what it can change is bounded — the dispatch already read the file, `wave-scope.json` (and with it `allowedPaths`) keeps its content-hash control notice, and `scope-echo --verify` turns a rewritten file into `digest-unknown`. The aggregate sidecar `filescopes/wave-<N>.scopes.json` is
|
|
38
|
+
Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory, so they never enter `allowedPaths` — that would grant agents write access to the deconfliction record. Until #1027 Pkt 5 writing them tripped `bash-write-verify` once per wave rollover; its `computeReport` now drops `<state-dir>/filescopes/**` from the violation set beside the manifest itself (`isScopeDeclarationPath()` in `scope-gate.mjs`, keyed on the ACTIVE manifest's state dir, so another harness dir's `filescopes/` still reports). The cost, named: a Bash rewrite of a scope file after dispatch is no longer reported there. For the per-agent files `filescopes/wave-<N>/<agent-id>.json` what it can change is bounded — the dispatch already read the file, `wave-scope.json` (and with it `allowedPaths`) keeps its content-hash control notice, and `scope-echo --verify` turns a rewritten file into `digest-unknown`. The aggregate sidecar `filescopes/wave-<N>.scopes.json` is re-read on every call (`readPeerScopeRecords()`), and a `peer-session-*` record there downgrades the matching violations to a "peer write" notice — so since #1504 point 5 its hash is BOUND to the manifest in the hook's trusted snapshot (`bindPeerSidecar()`): bound whenever the `wave-scope.json` hash changes, which fits the materialize → assert → union → write-manifest order. A sidecar that changes while the same manifest stands is untrusted: no peer record is honoured (those writes report as violations) until the manifest is rewritten, and a ⚠ notice names the file once per mismatching sidecar hash (HR-101 — not on every call). Named ceilings: a sidecar changed before the hook first observes the manifest is bound as found; one rewritten together with the manifest is covered only by the control-file notice; re-materializing the SAME wave without rewriting the manifest raises the notice once and leaves every peer record disabled until the manifest is rewritten — and because `--union` excludes `peer-session-*` records, a mid-wave Peer-Scope-Union changes the sidecar but not `allowedPaths`, so only the § 3.3 skeleton→final manifest rewrite changes the manifest hash and re-binds (the re-binding DEPENDS on that rewrite; `skills/_shared/parallel-aware-auq.md` § Peer-Scope-Union step 2 requires it); a forged complete snapshot record is believed (`trustedScopeState()`'s residual). No digest is written into any file the coordinator controls — a digest beside the sidecar sits where the same Bash line can rewrite it.
|
|
39
|
+
|
|
40
|
+
**A peer session's real manifest is reported once (#1511 e, named ceiling).** `.codex/wave-scope.json` or `<other-harness>/filescopes/**` written by a live peer appears once in this session's out-of-scope report (the report-once snapshot then holds it). The obvious exemption — "the manifest's binding names another session" — was shipped and withdrawn (e52bd786): the binding is what the file says about itself, so one Bash line could write a foreign-looking manifest and silence everything under that `filescopes/`. The hook cannot attribute a write to a caller either: as a `PostToolUse` hook it sees only the tree after the call and the PREVIOUS call's snapshot, so a peer's write between two calls and this call's own write land in the same window. Every other candidate signal is forgeable from inside this session as well: mtime (`touch -d`), a registry-live peer id (readable, so writable into the file), the harness directory. Until a signal exists that this session cannot produce, the one-time report stands.
|
|
39
41
|
|
|
40
42
|
The coordinator's **own** planned direct edits belong in `coordinator.json` in the identical form. They are not dispatches, so the hook can never see them (§ 6); the CLI check is the only gate that covers them.
|
|
41
43
|
|
|
@@ -111,7 +113,7 @@ It cannot compare a batch of siblings directly — the header records why, measu
|
|
|
111
113
|
|
|
112
114
|
- **Ledger:** `.orchestrator/wave-dispatch-scopes.<session>.json` — ONE FILE PER SESSION since #1493, `<session>` being the payload's `session_id` reduced to `[A-Za-z0-9._-]` (`ledgerPathsFor()`). Before, every session of the working copy shared one file holding one `waveKey`: claims of two sessions were never compared (the key's first segment is the session id), but a peer's dispatch reset the file and erased this session's live claims. The pre-split `.orchestrator/wave-dispatch-scopes.json` is read once for migration — adopted only while this session has no file yet and its `waveKey` names this session — and never written or deleted, since it may be a peer's live state. (Gitignored in this repo; consumer repos get the ignore line from bootstrap's `store-lock-ignore` step since #1495 — a repo bootstrapped earlier gets it on its next `--upgrade` or `--refresh-lock`), keyed `<session-id>|w<wave>|<role>` from `<state-dir>/wave-scope.json`.
|
|
113
115
|
- **Lock:** `.orchestrator/wave-dispatch-scopes.<session>.lock`, one per ledger file — the read-modify-write cycle runs under `withFileLock()` (the primitive behind the PSA-005 STATE.md lock). `writeJsonAtomicSync` makes the write atomic, never the *cycle*.
|
|
114
|
-
- **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `
|
|
116
|
+
- **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `decide()` then folds `./`, `//` and `/./` spellings together with `scopeEntrySpelling()` — the same rule `findScopeCollisions()` and `unionFileScopes()` apply, exported from `scope-gate.mjs` so no second copy can drift (#1510 g), and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
|
|
115
117
|
- **DENY** fires on exactly one condition: a collision involving THIS dispatch with a prior agent of the same wave that is **still in flight** (§ 5). The reason names the agent pair, the collision kind and the evidence paths; the suggestion is "give the file exactly ONE owner, or wait for the named agent(s) and re-dispatch", plus this session's ledger path to delete if the state is stale (never a peer's). The ledger is deliberately **not** persisted on a deny — the dispatch did not happen, so recording it would make the retry-after-fix look like a duplicate.
|
|
116
118
|
|
|
117
119
|
### 4.1 Error-class matrix — deliberately fail-**open**
|
|
@@ -214,7 +216,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
|
|
|
214
216
|
node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
|
|
215
217
|
```
|
|
216
218
|
|
|
217
|
-
Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan.
|
|
219
|
+
Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan. No manifest yet (e.g. `/plan`, before any `wave-scope.json` exists)? Add `--no-manifest` and drop the stdin redirect: the sidecar is checked alone and stdout stays empty — without the flag an empty stdin is still an error, and the flag refuses `--union` / `--assert-subset`, which need the manifest (#1510 f).
|
|
218
220
|
|
|
219
221
|
**Reset.** Delete this session's `.orchestrator/wave-dispatch-scopes.<session>.json` (and its `.lock` if a dead holder is suspected) — never a peer's. Both are gitignored here (in a consumer repo only once bootstrap's `store-lock-ignore` step has run, #1495), both are rebuilt on the next dispatch, and neither is shared with any other mechanism or session. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` and this session's ledger file (`skills/wave-executor/references/wave-loop-scope-manifest.md` step 7, session-end 3.0); a crashed session's file stays behind, inert — no other session reads it — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
|
|
220
222
|
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.6.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.6.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
package/hooks/on-stop.mjs
CHANGED
|
@@ -400,10 +400,16 @@ function reaperTriggerField(trigger) {
|
|
|
400
400
|
* @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
|
|
401
401
|
*/
|
|
402
402
|
async function handleStop(input, reaperTrigger = null) {
|
|
403
|
+
// Two roots, by writer (#1511 point b). `current-session.json` and
|
|
404
|
+
// `session.lock` (session id, lock heartbeat, duration) stay at the LAUNCH
|
|
405
|
+
// dir: on-session-start.mjs writes both at `resolveProjectDir()`. The wave
|
|
406
|
+
// and the commit/branch describe the tree the session WORKS in — after
|
|
407
|
+
// EnterWorktree that is the session root, not the launch checkout.
|
|
403
408
|
const projectRoot = getProjectDir();
|
|
409
|
+
const sessionRoot = resolveSessionRoot(input?.cwd);
|
|
404
410
|
|
|
405
|
-
const wave = await readWaveNumber(
|
|
406
|
-
const { commit, branch } = await gitInfo(
|
|
411
|
+
const wave = await readWaveNumber(sessionRoot, input);
|
|
412
|
+
const { commit, branch } = await gitInfo(sessionRoot);
|
|
407
413
|
|
|
408
414
|
const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
|
|
409
415
|
|
|
@@ -408,14 +408,11 @@ export { PEER_RECORD_PREFIX };
|
|
|
408
408
|
* @param {unknown} wave the manifest's `wave` field
|
|
409
409
|
* @returns {Array<{ id: string, files: string[] }>}
|
|
410
410
|
*/
|
|
411
|
-
export function readPeerScopeRecords(stateDir, wave) {
|
|
412
|
-
if (typeof
|
|
413
|
-
if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return [];
|
|
411
|
+
export function readPeerScopeRecords(stateDir, wave, raw = readPeerSidecarRaw(stateDir, wave)) {
|
|
412
|
+
if (typeof raw !== 'string') return [];
|
|
414
413
|
let parsed;
|
|
415
414
|
try {
|
|
416
|
-
parsed = JSON.parse(
|
|
417
|
-
readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8'),
|
|
418
|
-
);
|
|
415
|
+
parsed = JSON.parse(raw);
|
|
419
416
|
} catch {
|
|
420
417
|
return [];
|
|
421
418
|
}
|
|
@@ -429,6 +426,100 @@ export function readPeerScopeRecords(stateDir, wave) {
|
|
|
429
426
|
.map((r) => ({ id: r.id, files: r.files.filter((f) => typeof f === 'string') }));
|
|
430
427
|
}
|
|
431
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The raw bytes of a wave's aggregate scope sidecar, or null (absent,
|
|
431
|
+
* unreadable, or no usable state dir / wave). Read ONCE per call so the bytes
|
|
432
|
+
* that are hashed ({@link bindPeerSidecar}) are the bytes that are parsed.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} stateDir
|
|
435
|
+
* @param {unknown} wave
|
|
436
|
+
* @returns {string|null}
|
|
437
|
+
*/
|
|
438
|
+
export function readPeerSidecarRaw(stateDir, wave) {
|
|
439
|
+
if (typeof stateDir !== 'string' || stateDir === '') return null;
|
|
440
|
+
if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return null;
|
|
441
|
+
try {
|
|
442
|
+
return readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8');
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Bind the aggregate sidecar's content to the manifest it was written for
|
|
450
|
+
* (#1504 point 5). The sidecar carried no integrity anchor: a `peer-session-*`
|
|
451
|
+
* record appended AFTER dispatch downgraded matching violations to a peer-write
|
|
452
|
+
* notice, because the control hash covers only `wave-scope.json`.
|
|
453
|
+
*
|
|
454
|
+
* The anchor is the trusted snapshot's `scopeState` — the one record this hook
|
|
455
|
+
* already treats as control state. The coordinator's order is materialize →
|
|
456
|
+
* assert-disjoint → union → write manifest, so the sidecar is final by the time
|
|
457
|
+
* the manifest is; the hash is therefore (re)bound whenever the MANIFEST hash
|
|
458
|
+
* changes (or no binding exists yet), and must hold while the manifest stands.
|
|
459
|
+
* No coordinator step and no new field: a materializer-written digest would sit
|
|
460
|
+
* in a file the same actor can rewrite, which binds nothing.
|
|
461
|
+
*
|
|
462
|
+
* manifest changed / no prior binding → bind the current hash, honour records
|
|
463
|
+
* same manifest, same sidecar hash → honour records
|
|
464
|
+
* same manifest, sidecar now absent → nothing to honour; keep the binding so
|
|
465
|
+
* a re-created sidecar still mismatches
|
|
466
|
+
* same manifest, DIFFERENT sidecar → `tampered`: honour NO peer record
|
|
467
|
+
* (fail toward reporting) and keep the
|
|
468
|
+
* ORIGINAL binding until the manifest is
|
|
469
|
+
* rewritten; the notice fires ONCE per
|
|
470
|
+
* mismatching sidecar hash (`notify`)
|
|
471
|
+
*
|
|
472
|
+
* Once, not per call: a legitimate mid-wave Peer-Scope-Union rewrites the sidecar
|
|
473
|
+
* while `--union` (which excludes peer records) leaves the manifest bytes
|
|
474
|
+
* unchanged, so `tampered` holds for the rest of the wave. Repeating the ⚠ on
|
|
475
|
+
* every Bash call would be an always-on signal (HR-101). The marker
|
|
476
|
+
* `sidecarNotified` is persisted in the trusted snapshot by the caller only on
|
|
477
|
+
* the path that emits; a further change of the sidecar (a new hash) notifies again.
|
|
478
|
+
*
|
|
479
|
+
* A snapshot written before this field existed has no `sidecarHash` and binds
|
|
480
|
+
* silently on its first call — the same one-time adoption the control hash had.
|
|
481
|
+
*
|
|
482
|
+
* NAMED CEILING: a sidecar changed BEFORE this hook first observes the manifest
|
|
483
|
+
* (no trusted snapshot yet) is bound as found; so is one rewritten together with
|
|
484
|
+
* the manifest (that path already raises the control-file notice). A forged
|
|
485
|
+
* COMPLETE snapshot record is believed — `trustedScopeState`'s residual.
|
|
486
|
+
*
|
|
487
|
+
* @param {{ hash: string, sidecarHash?: unknown }|null} prevScopeState trusted prior state
|
|
488
|
+
* @param {string} manifestHash this call's manifest hash
|
|
489
|
+
* @param {string} sidecarHash this call's sidecar hash, or 'absent'
|
|
490
|
+
* @returns {{ sidecarHash: string, tampered: boolean, notify: boolean, sidecarNotified?: string }}
|
|
491
|
+
* the hash to persist, the verdict, whether to emit the mismatch notice, and
|
|
492
|
+
* (when tampered) the marker to persist once it has been emitted
|
|
493
|
+
*/
|
|
494
|
+
export function bindPeerSidecar(prevScopeState, manifestHash, sidecarHash) {
|
|
495
|
+
const bound = prevScopeState?.sidecarHash;
|
|
496
|
+
if (!prevScopeState || prevScopeState.hash !== manifestHash || typeof bound !== 'string') {
|
|
497
|
+
return { sidecarHash, tampered: false, notify: false };
|
|
498
|
+
}
|
|
499
|
+
if (bound === sidecarHash || sidecarHash === 'absent') {
|
|
500
|
+
return { sidecarHash: bound, tampered: false, notify: false };
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
sidecarHash: bound,
|
|
504
|
+
tampered: true,
|
|
505
|
+
notify: prevScopeState.sidecarNotified !== sidecarHash,
|
|
506
|
+
sidecarNotified: sidecarHash,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Render the sidecar-mismatch notice (#1504 point 5) — an alarm (⚠), because the
|
|
512
|
+
* consequence is that agreed peer writes now report as violations.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} relSidecarPath
|
|
515
|
+
* @returns {string}
|
|
516
|
+
*/
|
|
517
|
+
export function formatSidecarMismatchNotice(relSidecarPath) {
|
|
518
|
+
return `bash-write-verify: ${relSidecarPath} changed since wave-scope.json was written — `
|
|
519
|
+
+ 'its peer-session-* records are NOT honoured (writes under them report as violations) '
|
|
520
|
+
+ 'until the manifest is rewritten (#1504 point 5).';
|
|
521
|
+
}
|
|
522
|
+
|
|
432
523
|
/**
|
|
433
524
|
* Which peer record — if any — declared this path?
|
|
434
525
|
*
|
|
@@ -960,6 +1051,16 @@ async function main() {
|
|
|
960
1051
|
: 'unparseable',
|
|
961
1052
|
gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
|
|
962
1053
|
};
|
|
1054
|
+
// #1504 point 5 — bind the aggregate sidecar to this manifest (see
|
|
1055
|
+
// `bindPeerSidecar`). Part of `currentScopeState` so EVERY snapshot write
|
|
1056
|
+
// below, stand-down paths included, carries the binding forward.
|
|
1057
|
+
const sidecarRaw = readPeerSidecarRaw(path.dirname(scopePath), scope?.wave);
|
|
1058
|
+
const sidecarBinding = bindPeerSidecar(
|
|
1059
|
+
prevScopeState,
|
|
1060
|
+
currentScopeState.hash,
|
|
1061
|
+
sidecarRaw === null ? 'absent' : createHash('sha1').update(sidecarRaw).digest('hex').slice(0, 16),
|
|
1062
|
+
);
|
|
1063
|
+
currentScopeState.sidecarHash = sidecarBinding.sidecarHash;
|
|
963
1064
|
|
|
964
1065
|
// G3b (#1153 P1) — is this manifest even MINE?
|
|
965
1066
|
//
|
|
@@ -1153,6 +1254,9 @@ async function main() {
|
|
|
1153
1254
|
},
|
|
1154
1255
|
});
|
|
1155
1256
|
|
|
1257
|
+
// The mismatch notice below is emitted on this path only, so its once-marker
|
|
1258
|
+
// is persisted here and nowhere else (see `bindPeerSidecar`).
|
|
1259
|
+
if (sidecarBinding.tampered) currentScopeState.sidecarNotified = sidecarBinding.sidecarNotified;
|
|
1156
1260
|
writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
|
|
1157
1261
|
|
|
1158
1262
|
const messages = [];
|
|
@@ -1172,7 +1276,17 @@ async function main() {
|
|
|
1172
1276
|
// reachable. Such a write is agreed, not a bypass. Split it out of the violation report and
|
|
1173
1277
|
// name it, so the peer's file is countable without being an alarm. Sidecar
|
|
1174
1278
|
// absent ⇒ `peerRecords` is empty ⇒ every path stays a violation, unchanged.
|
|
1175
|
-
|
|
1279
|
+
// #1504 point 5 — a sidecar changed under a standing manifest is untrusted:
|
|
1280
|
+
// no peer record is honoured, and the mismatch is named.
|
|
1281
|
+
const peerRecords = sidecarBinding.tampered
|
|
1282
|
+
? []
|
|
1283
|
+
: readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
|
|
1284
|
+
if (sidecarBinding.notify) {
|
|
1285
|
+
messages.push(formatSidecarMismatchNotice(
|
|
1286
|
+
path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
|
|
1287
|
+
));
|
|
1288
|
+
warn = true;
|
|
1289
|
+
}
|
|
1176
1290
|
const violations = [];
|
|
1177
1291
|
for (const relPath of report) {
|
|
1178
1292
|
const peerId = peerRecordFor(relPath, peerRecords);
|
|
@@ -354,6 +354,13 @@ async function main() {
|
|
|
354
354
|
...(waveSignal !== null ? { wave_signal: waveSignal } : {}),
|
|
355
355
|
};
|
|
356
356
|
|
|
357
|
+
// Two roots, by writer (#1511 point a). `current-session.json` and
|
|
358
|
+
// `session.lock` stay at the LAUNCH dir: on-session-start.mjs writes both at
|
|
359
|
+
// `resolveProjectDir()`, and a reader elsewhere finds no record. Everything
|
|
360
|
+
// about the WAVE — its manifest, its start sha, the files it changed — is read
|
|
361
|
+
// at the SESSION root: after EnterWorktree the launch dir is a different
|
|
362
|
+
// checkout, whose HEAD and working tree say nothing about this wave.
|
|
363
|
+
const sessionRoot = resolveSessionRoot(input?.cwd);
|
|
357
364
|
const sessionFile = path.join(getProjectDir(), '.orchestrator', 'current-session.json');
|
|
358
365
|
|
|
359
366
|
const lastBatchResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
@@ -462,7 +469,7 @@ async function main() {
|
|
|
462
469
|
// last_wave_completed === last_wave and stays silent.
|
|
463
470
|
try {
|
|
464
471
|
const wave = await resolveWaveNumber(
|
|
465
|
-
|
|
472
|
+
sessionRoot,
|
|
466
473
|
new Set(readProcessLocalSessionIds({ hookInput: input })),
|
|
467
474
|
);
|
|
468
475
|
if (wave > 0) {
|
|
@@ -495,7 +502,7 @@ async function main() {
|
|
|
495
502
|
if (lastWave > lastWaveCompleted) {
|
|
496
503
|
// #980 — measure the closing wave's diff size BEFORE the new wave's
|
|
497
504
|
// start sha is persisted below. Unmeasurable → both keys omitted.
|
|
498
|
-
const filesChanged = countFilesChangedSince(
|
|
505
|
+
const filesChanged = countFilesChangedSince(sessionRoot, waveStartSha);
|
|
499
506
|
await emitEvent('orchestrator.wave.completed', {
|
|
500
507
|
wave_number: lastWave,
|
|
501
508
|
...(filesChanged !== null
|
|
@@ -537,7 +544,7 @@ async function main() {
|
|
|
537
544
|
// #980 — the OPEN half: stamp the sha this new wave starts from.
|
|
538
545
|
// Written as null (not omitted) when git is unreadable, so the
|
|
539
546
|
// PREVIOUS wave's sha can never linger and inflate the next count.
|
|
540
|
-
const startSha = readHeadSha(
|
|
547
|
+
const startSha = readHeadSha(sessionRoot);
|
|
541
548
|
const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
542
549
|
...current,
|
|
543
550
|
wave_start_sha: startSha,
|
|
@@ -593,7 +600,7 @@ async function main() {
|
|
|
593
600
|
// Rooted at the SESSION root (#1492 point 2, #1504 point 4): the quality gate
|
|
594
601
|
// registers its children in the ledger of the tree it runs in, which for an
|
|
595
602
|
// entered worktree is the worktree — the launch dir holds none of them.
|
|
596
|
-
await maybeTriggerOrphanScan({ projectDir:
|
|
603
|
+
await maybeTriggerOrphanScan({ projectDir: sessionRoot });
|
|
597
604
|
}
|
|
598
605
|
|
|
599
606
|
// Entry guard (#1393): run only as the node script the harness execs — a bare
|
|
@@ -29,7 +29,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
29
29
|
import os from 'node:os';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
32
|
-
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
32
|
+
import { getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
33
33
|
import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
34
34
|
import {
|
|
35
35
|
classifyManifestSession,
|
|
@@ -246,18 +246,23 @@ async function main() {
|
|
|
246
246
|
if (!MEMORY_PROPOSE_REGEX.test(command)) return emitAllow();
|
|
247
247
|
|
|
248
248
|
// Matched — proceed with audit logging (G4-G7)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
// Two roots, by writer (#1511 point c). The session-id fallback reads
|
|
250
|
+
// `current-session.json` where on-session-start.mjs WRITES it — the launch dir,
|
|
251
|
+
// through the same resolver (`resolveProjectDir`; the hand-rolled
|
|
252
|
+
// `CLAUDE_PROJECT_DIR ?? CODEX_PROJECT_DIR ?? process.cwd()` kept a set-but-
|
|
253
|
+
// empty var, skipped the Cursor/Pi vars and never walked up). The wave and the
|
|
254
|
+
// recorded `cwd` describe the tree the session WORKS in: the SESSION root the
|
|
255
|
+
// scope guards use (#1504 point 4), which after EnterWorktree is not the
|
|
256
|
+
// launch dir.
|
|
257
|
+
const projectDir = getProjectDir();
|
|
258
|
+
const sessionRoot = resolveSessionRoot(input.cwd);
|
|
252
259
|
|
|
253
260
|
// G4 — resolve session_id
|
|
254
261
|
const sessionId = await resolveSessionId(input, projectDir);
|
|
255
262
|
|
|
256
263
|
// G5 — resolve wave
|
|
257
|
-
// Read at the SESSION root the scope guards use (#1504 point 4), not at the
|
|
258
|
-
// launch dir above, which stays put while the session works in a worktree.
|
|
259
264
|
const wave = await resolveWave(
|
|
260
|
-
|
|
265
|
+
sessionRoot,
|
|
261
266
|
new Set(readProcessLocalSessionIds({ hookInput: input })),
|
|
262
267
|
);
|
|
263
268
|
|
|
@@ -280,7 +285,7 @@ async function main() {
|
|
|
280
285
|
command_hash: commandHash,
|
|
281
286
|
flags_present: flags,
|
|
282
287
|
argv_length: command.length,
|
|
283
|
-
cwd: relativeCwd(process.cwd(),
|
|
288
|
+
cwd: relativeCwd(process.cwd(), sessionRoot),
|
|
284
289
|
exit_code: null,
|
|
285
290
|
});
|
|
286
291
|
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
@@ -267,6 +267,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
267
267
|
/** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
|
|
268
268
|
/** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
|
|
269
269
|
let findScopeCollisions;
|
|
270
|
+
/** @type {typeof import('../scripts/lib/scope-gate.mjs').scopeEntrySpelling} */ let scopeEntrySpelling;
|
|
270
271
|
let findOwnScopeFile;
|
|
271
272
|
let readProcessLocalSessionIds;
|
|
272
273
|
let classifyManifestSession;
|
|
@@ -581,7 +582,7 @@ async function bootstrap() {
|
|
|
581
582
|
);
|
|
582
583
|
|
|
583
584
|
({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
|
|
584
|
-
({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
|
|
585
|
+
({ findScopeCollisions, findOwnScopeFile, scopeEntrySpelling } = modules.scopeGate);
|
|
585
586
|
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
586
587
|
({ withFileLock } = modules.fileLock);
|
|
587
588
|
({ scopeDigest } = modules.scopeEcho);
|
|
@@ -739,36 +740,6 @@ function cleanScopeLine(line) {
|
|
|
739
740
|
.trim();
|
|
740
741
|
}
|
|
741
742
|
|
|
742
|
-
/**
|
|
743
|
-
* Canonicalise a scope entry's SPELLING so two agents writing the same file two
|
|
744
|
-
* ways are not read as disjoint (review LOW). Measured before this existed:
|
|
745
|
-
* `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
|
|
746
|
-
* — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
|
|
747
|
-
* so both spellings reach the comparison verbatim.
|
|
748
|
-
*
|
|
749
|
-
* Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
|
|
750
|
-
* duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
|
|
751
|
-
* the directory-prefix operator of `pathMatchesPattern`, so stripping it would
|
|
752
|
-
* silently narrow a scope. The `dir` ↔ `dir/` case is handled by
|
|
753
|
-
* {@link promoteDirEntries}, which decides it on evidence rather than guessing.
|
|
754
|
-
*
|
|
755
|
-
* `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
|
|
756
|
-
* so the normalisation lives on THIS side of the call, applied to both sides of
|
|
757
|
-
* every comparison.
|
|
758
|
-
*
|
|
759
|
-
* @param {string} entry
|
|
760
|
-
* @returns {string}
|
|
761
|
-
*/
|
|
762
|
-
export function normalizeScopeEntry(entry) {
|
|
763
|
-
if (typeof entry !== 'string') return '';
|
|
764
|
-
let s = entry.trim();
|
|
765
|
-
if (s === '') return '';
|
|
766
|
-
s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
|
|
767
|
-
s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
|
|
768
|
-
while (s.startsWith('./')) s = s.slice(2);
|
|
769
|
-
return s;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
743
|
/**
|
|
773
744
|
* Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
|
|
774
745
|
* evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
|
|
@@ -815,7 +786,11 @@ function collectScopePaths(segments) {
|
|
|
815
786
|
for (const raw of segments) {
|
|
816
787
|
// A trailing sentence period is punctuation, never part of a path
|
|
817
788
|
// (measured: "docs/events-schema.md." at the end of an inline declaration).
|
|
818
|
-
|
|
789
|
+
// Lexical cleaning only (prose → path); the SPELLING rule is
|
|
790
|
+
// `scopeEntrySpelling` in scope-gate.mjs, applied in `decide()` (#1510 g).
|
|
791
|
+
// The second trim: a period stripped after `cleanScopeLine`'s trim can
|
|
792
|
+
// expose a space (`foo.mjs .`), which `looksLikeRepoPath` would reject.
|
|
793
|
+
const cleaned = cleanScopeLine(raw).replace(/\.$/, '').trim();
|
|
819
794
|
if (!looksLikeRepoPath(cleaned)) continue;
|
|
820
795
|
if (seen.has(cleaned)) continue;
|
|
821
796
|
seen.add(cleaned);
|
|
@@ -906,7 +881,8 @@ export function extractScopeSignal(prompt) {
|
|
|
906
881
|
* (and the tests that pin them) keep the original signature.
|
|
907
882
|
*
|
|
908
883
|
* @param {string} prompt
|
|
909
|
-
* @returns {string[]} repo-relative paths/globs
|
|
884
|
+
* @returns {string[]} repo-relative paths/globs as declared (lexically cleaned,
|
|
885
|
+
* deduped, order preserved) — the SPELLING rule runs in `decide()` (#1510 g)
|
|
910
886
|
*/
|
|
911
887
|
export function extractScopeFromPrompt(prompt) {
|
|
912
888
|
return extractScopeSignal(prompt).files;
|
|
@@ -2070,9 +2046,11 @@ export function scopeDigestFields(files, prompt, digestFn) {
|
|
|
2070
2046
|
* @param {string} [params.nowIso] dispatch timestamp recorded on the entry
|
|
2071
2047
|
* @param {(paths: string[]) => string} [params.digestFn] scope-digest function
|
|
2072
2048
|
* (injected for testability; defaults to the late-bound `scopeDigest`)
|
|
2049
|
+
* @param {(entry: string) => string} [params.spellFn] scope-entry spelling rule
|
|
2050
|
+
* (injected for testability; defaults to the late-bound `scopeEntrySpelling`)
|
|
2073
2051
|
* @returns {Verdict}
|
|
2074
2052
|
*/
|
|
2075
|
-
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn }) {
|
|
2053
|
+
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn, spellFn }) {
|
|
2076
2054
|
const toolName = input?.tool_name;
|
|
2077
2055
|
// Row 4: not our tool.
|
|
2078
2056
|
if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
|
|
@@ -2083,7 +2061,14 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
2083
2061
|
const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
|
|
2084
2062
|
|
|
2085
2063
|
const signal = extractScopeSignal(toolInput.prompt);
|
|
2086
|
-
|
|
2064
|
+
// #1510 g — ONE spelling rule, scope-gate's, applied before the ledger, the
|
|
2065
|
+
// digest and `promoteDirEntries` see an entry: `./scripts/lib` must reach the
|
|
2066
|
+
// dir-promotion as `scripts/lib`, or a bare-dir claim stays a literal and
|
|
2067
|
+
// compares disjoint with the files beneath it. Re-deduped on the spelling.
|
|
2068
|
+
// Unbound (a direct importer that never ran `bootstrap()`) ⇒ entries as
|
|
2069
|
+
// extracted; `findScopeCollisions` still spells both sides itself.
|
|
2070
|
+
const spell = typeof spellFn === 'function' ? spellFn : scopeEntrySpelling;
|
|
2071
|
+
const files = typeof spell === 'function' ? [...new Set(signal.files.map(spell))] : signal.files;
|
|
2087
2072
|
const id = agentIdOf(toolInput);
|
|
2088
2073
|
const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
|
|
2089
2074
|
? ledger.agents
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
// `git commit --no-verify` skips it entirely (operator opts out by name).
|
|
49
49
|
|
|
50
50
|
import { execSync } from 'node:child_process';
|
|
51
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
-
import { join, relative } from 'node:path';
|
|
51
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
52
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
53
53
|
|
|
54
54
|
// Re-use existing helpers from scripts/lib/hardening.mjs.
|
|
55
55
|
// IMPORTANT: import is resolved relative to THIS file's location (the hook
|
|
@@ -59,6 +59,7 @@ import { join, relative } from 'node:path';
|
|
|
59
59
|
import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
60
60
|
import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
|
|
61
61
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
62
|
+
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
62
63
|
import { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
|
|
63
64
|
import {
|
|
64
65
|
classifyManifestSession,
|
|
@@ -175,14 +176,20 @@ function mtimeMsOf(file) {
|
|
|
175
176
|
*
|
|
176
177
|
* @param {object} p
|
|
177
178
|
* @param {Record<string, unknown>} p.scope parsed manifest
|
|
178
|
-
* @param {string[]} p.stagedFiles
|
|
179
|
+
* @param {string[]} p.stagedFiles repo-relative, as `git diff --cached` prints them
|
|
180
|
+
* @param {(f: string) => string|null} [p.scopePathOf] a staged path as the
|
|
181
|
+
* manifest names it — relative to the SESSION root; identity when that is the
|
|
182
|
+
* repo root. `null` = the path lies OUTSIDE the session root: a violation
|
|
183
|
+
* without pattern matching, since a `../`-prefixed path would match a glob
|
|
184
|
+
* like `**\/*.mjs` and enforce-scope denies every write outside its root
|
|
185
|
+
* (REQ-04).
|
|
179
186
|
* @param {string} p.scopeRel manifest path for messages, repo-relative
|
|
180
187
|
* @param {Set<string>} p.ownIds this process's session ids
|
|
181
188
|
* @param {number|null} p.scopeMtimeMs
|
|
182
189
|
* @param {number|null} p.sessionAge ms since the newest session clock
|
|
183
190
|
* @returns {{action: 'pass'|'report'|'block', lines: string[]}}
|
|
184
191
|
*/
|
|
185
|
-
function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
192
|
+
function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
186
193
|
if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
|
|
187
194
|
|
|
188
195
|
// Same classifier and same disposition as enforce-scope Gate 3b: only a
|
|
@@ -200,7 +207,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
|
|
|
200
207
|
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
201
208
|
if (allowedPaths.length > 0) {
|
|
202
209
|
const violations = stagedFiles.filter(
|
|
203
|
-
(f) =>
|
|
210
|
+
(f) => {
|
|
211
|
+
const scoped = scopePathOf(f);
|
|
212
|
+
return scoped === null || !allowedPaths.some((pattern) => pathMatchesPattern(scoped, pattern));
|
|
213
|
+
},
|
|
204
214
|
);
|
|
205
215
|
if (violations.length === 0) return { action: 'pass', lines: [] };
|
|
206
216
|
// Blocks under `warn` too — see the header: in a warn-mode wave this is
|
|
@@ -258,6 +268,30 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
|
|
|
258
268
|
};
|
|
259
269
|
}
|
|
260
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The session root a commit at `repoRoot` is judged against when it lies
|
|
273
|
+
* STRICTLY inside the repo, else `null` (judge at `repoRoot`, as before) — see
|
|
274
|
+
* the call site in `main()` (#1511 point d). Both sides realpath-resolved, so
|
|
275
|
+
* macOS `/var` vs `/private/var` cannot split one directory in two.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} repoRoot `git rev-parse --show-toplevel`
|
|
278
|
+
* @returns {{sessionRoot: string, realRepoRoot: string}|null}
|
|
279
|
+
*/
|
|
280
|
+
function subdirSessionRoot(repoRoot) {
|
|
281
|
+
const canon = (p) => {
|
|
282
|
+
// `.native` canonicalises letter case on case-insensitive APFS (as
|
|
283
|
+
// enforce-scope's clamp does since #1504 pt 8); the JS realpath keeps a
|
|
284
|
+
// case-different CLAUDE_PROJECT_DIR verbatim, which relativises to `../..`
|
|
285
|
+
// against the git toplevel and switches the subdirectory mode off silently.
|
|
286
|
+
try { return realpathSync.native(p); } catch { return p; }
|
|
287
|
+
};
|
|
288
|
+
const realRepoRoot = canon(repoRoot);
|
|
289
|
+
const sessionRoot = canon(resolveSessionRoot(repoRoot, repoRoot));
|
|
290
|
+
const rel = relative(realRepoRoot, sessionRoot);
|
|
291
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
292
|
+
return { sessionRoot, realRepoRoot };
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
/**
|
|
262
296
|
* Both sub-modes, in the order the pre-commit hook needs them. Every statement
|
|
263
297
|
* here used to sit at module top level, which meant a bare `import()` of this
|
|
@@ -282,8 +316,34 @@ async function main() {
|
|
|
282
316
|
// committing session's env; there is no hook payload here, so the env tier is
|
|
283
317
|
// the only process-local identity.
|
|
284
318
|
const ownIds = new Set(readProcessLocalSessionIds());
|
|
285
|
-
|
|
286
|
-
|
|
319
|
+
|
|
320
|
+
// #1511 point d — ALSO read the manifest at the SESSION root the scope guards
|
|
321
|
+
// resolve (`resolveSessionRoot`, enforce-scope.mjs): a session launched in a
|
|
322
|
+
// repo SUBDIRECTORY keeps its manifest there, so the repo root held none and
|
|
323
|
+
// sub-mode B never ran for it. git runs this hook at the worktree top level
|
|
324
|
+
// and the committing session's env (the launch dir) is inherited, so the
|
|
325
|
+
// toplevel stands in for the payload `cwd` the Edit/Write guards get. Only a
|
|
326
|
+
// root STRICTLY inside the repo is adopted: the one rung that answers above it
|
|
327
|
+
// (a harness `agent-<hex>` worktree lifted to its launch checkout) would judge
|
|
328
|
+
// this repo's staged paths against another tree's manifest.
|
|
329
|
+
//
|
|
330
|
+
// The repo-root manifest is ALWAYS judged exactly as before #1511 d, and the
|
|
331
|
+
// subdirectory manifest is judged IN ADDITION, each against its own path
|
|
332
|
+
// mapping; either one blocking blocks. So the verdict is never laxer than the
|
|
333
|
+
// repo-root-only guard it extends — choosing ONE of the two manifests let an
|
|
334
|
+
// unbound or `enforcement: off` subdirectory manifest silence a repo-root wave
|
|
335
|
+
// that blocked the same commit (second review, MED-1).
|
|
336
|
+
const subdir = subdirSessionRoot(repoRoot);
|
|
337
|
+
const judgements = [{ root: repoRoot, scopePathOf: (f) => f }];
|
|
338
|
+
if (subdir !== null) {
|
|
339
|
+
judgements.push({
|
|
340
|
+
root: subdir.sessionRoot,
|
|
341
|
+
scopePathOf: (f) => {
|
|
342
|
+
const rel = relative(subdir.sessionRoot, join(subdir.realRepoRoot, f));
|
|
343
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel;
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
287
347
|
const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
|
|
288
348
|
|
|
289
349
|
// -------------------------------------------------------------------------
|
|
@@ -293,7 +353,11 @@ async function main() {
|
|
|
293
353
|
const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
|
|
294
354
|
const stagedFiles = stagedOutput.split('\n').filter(Boolean);
|
|
295
355
|
|
|
296
|
-
|
|
356
|
+
let blocked = false;
|
|
357
|
+
for (const { root, scopePathOf } of judgements) {
|
|
358
|
+
const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
|
|
359
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
360
|
+
if (!scopePath) continue;
|
|
297
361
|
let scope;
|
|
298
362
|
try {
|
|
299
363
|
scope = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
@@ -305,14 +369,16 @@ async function main() {
|
|
|
305
369
|
const verdict = scopeCommitVerdict({
|
|
306
370
|
scope: scope !== null && typeof scope === 'object' ? scope : {},
|
|
307
371
|
stagedFiles,
|
|
372
|
+
scopePathOf,
|
|
308
373
|
scopeRel: relative(repoRoot, scopePath) || scopePath,
|
|
309
374
|
ownIds,
|
|
310
375
|
scopeMtimeMs: mtimeMsOf(scopePath),
|
|
311
|
-
sessionAge: sessionAgeMs(
|
|
376
|
+
sessionAge: sessionAgeMs(root),
|
|
312
377
|
});
|
|
313
378
|
for (const line of verdict.lines) process.stderr.write(`${line}\n`);
|
|
314
|
-
if (verdict.action === 'block')
|
|
379
|
+
if (verdict.action === 'block') blocked = true;
|
|
315
380
|
}
|
|
381
|
+
if (blocked) process.exit(1);
|
|
316
382
|
|
|
317
383
|
// -------------------------------------------------------------------------
|
|
318
384
|
// Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.6.0",
|
|
4
|
+
"description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
7
7
|
"keywords": [
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
"orchestration",
|
|
17
17
|
"developer-tools",
|
|
18
18
|
"mcp",
|
|
19
|
-
"llm"
|
|
19
|
+
"llm",
|
|
20
|
+
"workflow",
|
|
21
|
+
"quality-gates",
|
|
22
|
+
"ai-coding",
|
|
23
|
+
"gitlab",
|
|
24
|
+
"github"
|
|
20
25
|
],
|
|
21
26
|
"engines": {
|
|
22
27
|
"node": ">=24.0.0"
|
|
@@ -1295,10 +1295,17 @@ function normalizeAgentScopes(agentScopes) {
|
|
|
1295
1295
|
* NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
|
|
1296
1296
|
* when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
|
|
1297
1297
|
*
|
|
1298
|
+
* EXPORTED as the ONE spelling rule (#1510 g): `hooks/pre-task-scope-disjoint.mjs`
|
|
1299
|
+
* applies it (late-bound) before its own dedupe and dir-promotion, instead of a
|
|
1300
|
+
* hand-copied twin that had already drifted (`a/././b` kept a `./` segment).
|
|
1301
|
+
* Whitespace is NOT trimmed here: no writer of a scope entry produces padding
|
|
1302
|
+
* (the hook's prose cleaner trims before this rule runs; sidecar entries are
|
|
1303
|
+
* JSON strings the plan writes), and trimming would rewrite a declared path.
|
|
1304
|
+
*
|
|
1298
1305
|
* @param {string} entry - a non-empty scope entry
|
|
1299
1306
|
* @returns {string}
|
|
1300
1307
|
*/
|
|
1301
|
-
function scopeEntrySpelling(entry) {
|
|
1308
|
+
export function scopeEntrySpelling(entry) {
|
|
1302
1309
|
const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
|
|
1303
1310
|
// `./` alone would spell as '' — keep the entry rather than invent a meaning.
|
|
1304
1311
|
return spelled.length > 0 ? spelled : entry;
|
|
@@ -348,8 +348,11 @@ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
|
348
348
|
* @param {string} eventsPath
|
|
349
349
|
* @param {string} id
|
|
350
350
|
* @param {number|null} deadlineMs — epoch ms the scan must end by; `null` = unbounded
|
|
351
|
-
* @returns {Array<object
|
|
352
|
-
* scan or cut it short
|
|
351
|
+
* @returns {{records: Array<object>, lostFields: string[]}|null} `null` when the
|
|
352
|
+
* deadline had passed before the scan or cut it short, or a recoverable cut
|
|
353
|
+
* lies in the session's window — a partial history is never handed on as a
|
|
354
|
+
* whole one. `lostFields` names the record fields a PERMANENTLY pruned archive
|
|
355
|
+
* inside the window may have changed (see {@link windowGapVerdict}).
|
|
353
356
|
*/
|
|
354
357
|
function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
355
358
|
let budgetMs;
|
|
@@ -358,7 +361,7 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
358
361
|
if (!(budgetMs > 0)) return null;
|
|
359
362
|
}
|
|
360
363
|
const newestFirst = [];
|
|
361
|
-
const { truncated, unreadable } = scanEventsBackwards({
|
|
364
|
+
const { truncated, unreadable, gaps } = scanEventsBackwards({
|
|
362
365
|
filePath: eventsPath,
|
|
363
366
|
filter: id,
|
|
364
367
|
budgetMs,
|
|
@@ -371,13 +374,73 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
371
374
|
// deadline does — its records are simply absent — so it defers the candidate
|
|
372
375
|
// the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
|
|
373
376
|
// candidates on every run (`skipped-history-unread`, visible, never a record
|
|
374
|
-
// dated NOW).
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
// dated NOW).
|
|
378
|
+
if (truncated || (Array.isArray(unreadable) && unreadable.length > 0)) return null;
|
|
379
|
+
const verdict = windowGapVerdict(gaps, newestFirst);
|
|
380
|
+
if (verdict.defer) return null;
|
|
381
|
+
return { records: newestFirst.reverse(), lostFields: verdict.lostFields };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* What a pruned/unindexed archive (`gaps` of {@link scanEventsBackwards}) means
|
|
386
|
+
* for the session whose found records are `records` (#1512).
|
|
387
|
+
*
|
|
388
|
+
* The walk reports every gap of the WHOLE ledger, mostly far older than the
|
|
389
|
+
* candidate; deferring on any of them would skip the candidate forever. So a
|
|
390
|
+
* gap counts only when its `[first_ts, last_ts]` intersects the session's
|
|
391
|
+
* window `[earliest, latest]` timestamp of the records the walk found.
|
|
392
|
+
*
|
|
393
|
+
* A counted gap's KIND then decides. `unindexed-archive` — the file still sits
|
|
394
|
+
* in `_archive/` under a name the walk does not index — can resolve (a rename
|
|
395
|
+
* puts it back), so it DEFERS (`skipped-history-unread`). `missing-archive` —
|
|
396
|
+
* pruned, its bytes gone — never resolves, so deferring would skip the candidate
|
|
397
|
+
* on every run forever; the record is written instead, naming in `lostFields`
|
|
398
|
+
* what the lost span may have held: `session_type` and `session_profile` (the
|
|
399
|
+
* LAST `shape_resolved` decides both) always, `completed_at` when the gap reaches past the latest found
|
|
400
|
+
* record. An unknown kind defers (fail toward not writing a wrong record).
|
|
401
|
+
*
|
|
402
|
+
* Fail direction (BV-004): a gap missing either bound, or a session with no
|
|
403
|
+
* dated record, is NOT counted — an unbounded gap would intersect every later
|
|
404
|
+
* session and recreate the defer-forever bug. Such a candidate is written as
|
|
405
|
+
* before #1512, its unread start listed in `_backfill_incomplete_fields`.
|
|
406
|
+
* Likewise a gap that ate the session's START lies just before the earliest
|
|
407
|
+
* found record and stays outside the window — same incomplete-start answer.
|
|
408
|
+
* Symmetrically a gap that starts AFTER the newest found record (it ate the
|
|
409
|
+
* session's END) is not counted either, so a `shape_resolved` or stop event
|
|
410
|
+
* lost there goes unnamed in `lostFields`; revisit if a backfilled record's
|
|
411
|
+
* `completed_at` is later contradicted by a recovered archive.
|
|
412
|
+
* Ceiling: an `unindexed-archive` nobody renames back defers its candidate on
|
|
413
|
+
* every run, visibly. Revisit if tombstones without `first_ts`/`last_ts` still
|
|
414
|
+
* appear in ledgers, or if `skipped-history-unread` persists across many runs.
|
|
415
|
+
*
|
|
416
|
+
* @param {Array<{kind?: string, first_ts?: string|null, last_ts?: string|null}>|undefined} gaps
|
|
417
|
+
* @param {Array<object>} records
|
|
418
|
+
* @returns {{defer: boolean, lostFields: string[]}}
|
|
419
|
+
*/
|
|
420
|
+
function windowGapVerdict(gaps, records) {
|
|
421
|
+
const none = { defer: false, lostFields: [] };
|
|
422
|
+
if (!Array.isArray(gaps) || gaps.length === 0) return none;
|
|
423
|
+
let lo = Infinity;
|
|
424
|
+
let hi = -Infinity;
|
|
425
|
+
for (const r of records) {
|
|
426
|
+
const ts = typeof r?.timestamp === 'string' ? Date.parse(r.timestamp) : NaN;
|
|
427
|
+
if (Number.isNaN(ts)) continue;
|
|
428
|
+
lo = Math.min(lo, ts);
|
|
429
|
+
hi = Math.max(hi, ts);
|
|
430
|
+
}
|
|
431
|
+
if (lo > hi) return none;
|
|
432
|
+
const lost = new Set();
|
|
433
|
+
for (const g of gaps) {
|
|
434
|
+
const first = typeof g?.first_ts === 'string' ? Date.parse(g.first_ts) : NaN;
|
|
435
|
+
const last = typeof g?.last_ts === 'string' ? Date.parse(g.last_ts) : NaN;
|
|
436
|
+
if (Number.isNaN(first) || Number.isNaN(last)) continue;
|
|
437
|
+
if (!(first <= hi && last >= lo)) continue;
|
|
438
|
+
if (g.kind !== 'missing-archive') return { defer: true, lostFields: [] };
|
|
439
|
+
lost.add('session_type');
|
|
440
|
+
lost.add('session_profile'); // same latest-wins `shape_resolved` record
|
|
441
|
+
if (last > hi) lost.add('completed_at');
|
|
442
|
+
}
|
|
443
|
+
return { defer: false, lostFields: [...lost] };
|
|
381
444
|
}
|
|
382
445
|
|
|
383
446
|
/**
|
|
@@ -414,19 +477,21 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
|
414
477
|
* @param {string} eventsPath
|
|
415
478
|
* @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
|
|
416
479
|
* @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
|
|
417
|
-
* @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true }}
|
|
480
|
+
* @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true, historyLostFields?: string[] }}
|
|
418
481
|
*/
|
|
419
482
|
function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
|
|
420
483
|
const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
|
|
421
484
|
if (gathered.startedAt !== null) return gathered;
|
|
422
485
|
try {
|
|
423
486
|
let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
|
|
487
|
+
let bridgeLost = [];
|
|
424
488
|
if (uuid === null) {
|
|
425
489
|
const label = ids.semanticSessionId || ids.sessionId;
|
|
426
490
|
if (!label) return gathered;
|
|
427
491
|
const bridge = recordsMentioning(eventsPath, label, deadlineMs);
|
|
428
492
|
if (bridge === null) return { ...gathered, historyUnread: true };
|
|
429
|
-
|
|
493
|
+
bridgeLost = bridge.lostFields;
|
|
494
|
+
const bridged = collectSessionEvents(bridge.records, ids).uuids;
|
|
430
495
|
if (bridged.size !== 1) return gathered;
|
|
431
496
|
[uuid] = bridged;
|
|
432
497
|
}
|
|
@@ -434,7 +499,12 @@ function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
|
|
|
434
499
|
if (history === null) return { ...gathered, historyUnread: true };
|
|
435
500
|
// Empty only when the scan found no source at all — e.g. a reader seam the
|
|
436
501
|
// filesystem does not back; the active answer stands.
|
|
437
|
-
|
|
502
|
+
if (history.records.length === 0) return gathered;
|
|
503
|
+
const collected = collectSessionEvents(history.records, ids);
|
|
504
|
+
// #1512: a permanently pruned archive inside the window — written, with
|
|
505
|
+
// what it may have held named instead of deferring forever.
|
|
506
|
+
const lostFields = [...new Set([...bridgeLost, ...history.lostFields])];
|
|
507
|
+
return lostFields.length > 0 ? { ...collected, historyLostFields: lostFields } : collected;
|
|
438
508
|
} catch {
|
|
439
509
|
return gathered;
|
|
440
510
|
}
|
|
@@ -559,6 +629,9 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
|
|
|
559
629
|
// no terminal event was found, so mark it incomplete so downstream duration
|
|
560
630
|
// consumers can tell an events-attested end apart from an estimate (#914 R1).
|
|
561
631
|
if (completedEstimated) incomplete.push('completed_at');
|
|
632
|
+
// #1512: fields a permanently pruned archive inside the session window may
|
|
633
|
+
// have held (`windowGapVerdict`), named rather than deferring forever.
|
|
634
|
+
for (const f of gathered.historyLostFields ?? []) if (!incomplete.includes(f)) incomplete.push(f);
|
|
562
635
|
|
|
563
636
|
const record = {
|
|
564
637
|
session_id: recordId,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* cat wave-scope.json | node scripts/validate-wave-scope.mjs
|
|
11
11
|
* node scripts/validate-wave-scope.mjs --assert-subset <agent-filescope.json> < wave-scope.json
|
|
12
12
|
* node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> < wave-scope.json
|
|
13
|
+
* node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> --no-manifest
|
|
13
14
|
* node scripts/validate-wave-scope.mjs --union <agent-scopes.json> < wave-scope.json
|
|
14
15
|
*
|
|
15
16
|
* Flags:
|
|
@@ -38,6 +39,17 @@
|
|
|
38
39
|
* Runs even when the manifest fails schema validation
|
|
39
40
|
* (#1026.4): it reads only the sidecar, so schema
|
|
40
41
|
* errors and collisions are reported together (exit 1).
|
|
42
|
+
* --no-manifest #1510 f. With --assert-disjoint ONLY: check the
|
|
43
|
+
* sidecar alone and read NO manifest (no stdin, no
|
|
44
|
+
* positional file) — `/plan` checks disjointness
|
|
45
|
+
* before any wave-scope.json exists and had to pipe
|
|
46
|
+
* a fabricated dummy. Stdout stays EMPTY (there is no
|
|
47
|
+
* manifest to echo); exit 0 = disjoint, 1 = collision.
|
|
48
|
+
* An explicit flag, never inferred from empty stdin:
|
|
49
|
+
* WITHOUT it, empty/missing stdin stays an error
|
|
50
|
+
* (fail-closed, #1083 class). Rejected (exit 1) with
|
|
51
|
+
* --union / --assert-subset / --expand-test-siblings
|
|
52
|
+
* or a positional path — those need a manifest.
|
|
41
53
|
* --union <path> #1020. QUERY MODE. Read the same sidecar, compute
|
|
42
54
|
* `expandTestSiblings(unionFileScopes(scopes), {role})`
|
|
43
55
|
* using the MANIFEST'S OWN `role`, and print the
|
|
@@ -161,7 +173,8 @@ function flagValue(argv, i, flag) {
|
|
|
161
173
|
*
|
|
162
174
|
* @param {string[]} argv - full process.argv
|
|
163
175
|
* @returns {{ assertSubset: string|null, expandTestSiblings: boolean,
|
|
164
|
-
* assertDisjoint: string|null, union: string|null,
|
|
176
|
+
* assertDisjoint: string|null, union: string|null, noManifest: boolean,
|
|
177
|
+
* positionals: string[] }}
|
|
165
178
|
*/
|
|
166
179
|
function parseArgs(argv) {
|
|
167
180
|
const positionals = [];
|
|
@@ -169,6 +182,7 @@ function parseArgs(argv) {
|
|
|
169
182
|
let expandTestSiblings = false;
|
|
170
183
|
let assertDisjoint = null;
|
|
171
184
|
let union = null;
|
|
185
|
+
let noManifest = false;
|
|
172
186
|
for (let i = 2; i < argv.length; i++) {
|
|
173
187
|
const a = argv[i];
|
|
174
188
|
if (a === '--expand-test-siblings') {
|
|
@@ -185,11 +199,27 @@ function parseArgs(argv) {
|
|
|
185
199
|
} else if (a === '--union') {
|
|
186
200
|
union = flagValue(argv, i, '--union');
|
|
187
201
|
i++; // consume the value
|
|
202
|
+
} else if (a === '--no-manifest') {
|
|
203
|
+
noManifest = true;
|
|
188
204
|
} else {
|
|
189
205
|
positionals.push(a);
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
|
-
|
|
208
|
+
if (noManifest) {
|
|
209
|
+
// #1510 f — every other mode reads the MANIFEST (`role`, `allowedPaths`), so
|
|
210
|
+
// combining it with "there is no manifest" is a contradiction, not a no-op.
|
|
211
|
+
if (assertDisjoint === null) die('--no-manifest requires --assert-disjoint <agent-scopes.json>', 1);
|
|
212
|
+
const needsManifest = [
|
|
213
|
+
union !== null && '--union',
|
|
214
|
+
assertSubset !== null && '--assert-subset',
|
|
215
|
+
expandTestSiblings && '--expand-test-siblings',
|
|
216
|
+
positionals.length > 0 && `a wave-scope.json path (${positionals[0]})`,
|
|
217
|
+
].filter(Boolean);
|
|
218
|
+
if (needsManifest.length > 0) {
|
|
219
|
+
die(`--no-manifest cannot be combined with ${needsManifest.join(', ')}: that mode reads the manifest`, 1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { assertSubset, expandTestSiblings, assertDisjoint, union, noManifest, positionals };
|
|
193
223
|
}
|
|
194
224
|
|
|
195
225
|
/**
|
|
@@ -755,7 +785,12 @@ function validate(
|
|
|
755
785
|
process.stdout.write(input.endsWith('\n') ? input : input + '\n');
|
|
756
786
|
}
|
|
757
787
|
|
|
758
|
-
const { assertSubset, expandTestSiblings, assertDisjoint, union, positionals } = parseArgs(
|
|
788
|
+
const { assertSubset, expandTestSiblings, assertDisjoint, union, noManifest, positionals } = parseArgs(
|
|
759
789
|
process.argv,
|
|
760
790
|
);
|
|
761
|
-
|
|
791
|
+
if (noManifest) {
|
|
792
|
+
// #1510 f — sidecar-only: stdin is never read, so no dummy manifest is needed.
|
|
793
|
+
assertDisjointOrDie(assertDisjoint);
|
|
794
|
+
} else {
|
|
795
|
+
validate(readInput(positionals[0]), assertSubset, expandTestSiblings, assertDisjoint, union);
|
|
796
|
+
}
|
|
@@ -113,7 +113,7 @@ In-place beside a peer is survivable when the two scopes are DECLARED to each ot
|
|
|
113
113
|
Four steps, in order. Steps 1 and 2 are the protocol; 3 and 4 are what keeps it honest.
|
|
114
114
|
|
|
115
115
|
1. **Declare the COMPLETE path list.** The arriving session sends the peer every path it will write — including the ones a script produces (fixtures, snapshots, result files, temp helpers), not only the ones it plans to edit by hand. A path omitted here is a path the peer's guard reports as a violation. In the same message it adopts the peer's resource rules.
|
|
116
|
-
2. **The peer unions.** The receiving coordinator adds those paths to its `allowedPaths` as ONE record `peer-session-<id>` in the wave's scope manifest, re-asserts subset/disjointness, and re-materializes them on every wave rollover (`skills/wave-executor/wave-loop.md` § Scope Manifest).
|
|
116
|
+
2. **The peer unions.** The receiving coordinator adds those paths to its `allowedPaths` as ONE record `peer-session-<id>` in the wave's scope manifest, re-asserts subset/disjointness, and re-materializes them on every wave rollover (`skills/wave-executor/wave-loop.md` § Scope Manifest). Mid-wave, re-run the FULL sequence of `skills/wave-executor/references/wave-loop-scope-manifest.md` § 3, including § 3.3's skeleton write (`"allowedPaths": []`) before the final manifest: `--union` excludes `peer-session-*` records, so re-materializing the sidecar alone leaves the manifest bytes unchanged, `bash-write-verify` keeps the sidecar bound to the OLD hash, and every agreed peer write then reports as a violation for the rest of the wave (#1504 point 5). The skeleton rewrite changes the manifest hash, which re-binds the new sidecar.
|
|
117
117
|
3. **Probe with ONE real write.** Before dispatching any agent, make one small PLANNED Edit from the declared list. A denial here costs one edit; the same denial found after a fan-out costs the wave.
|
|
118
118
|
4. **Announce before committing.** The arriving session sends its final file list, the peer sequences its own push behind it, and the SHAs come back. The git index is shared (PSA-007) — sequencing it is the only thing that makes two sessions in one checkout committable.
|
|
119
119
|
|
|
@@ -100,7 +100,7 @@ Before each wave dispatch:
|
|
|
100
100
|
--assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Exit 1 (one stderr message per collision) means two agents were handed the same file: fix the session plan, re-materialize, re-assert. Never widen the union to make it pass. This runs **before** 3.3 because a union computed over colliding scopes launders the defect into the very artefact meant to prevent it — `allowedPaths` then grants the file and every later gate sees a legal write.
|
|
103
|
+
Exit 1 (one stderr message per collision) means two agents were handed the same file: fix the session plan, re-materialize, re-assert. Where no manifest exists yet (`/plan` checking a draft), pass `--no-manifest` instead of piping a fabricated one — it checks the sidecar alone, and is rejected together with `--union` / `--assert-subset` (#1510 f). Never widen the union to make it pass. This runs **before** 3.3 because a union computed over colliding scopes launders the defect into the very artefact meant to prevent it — `allowedPaths` then grants the file and every later gate sees a legal write.
|
|
104
104
|
|
|
105
105
|
**3.3 — compute the union.** `--union` is a QUERY MODE that still requires a schema-valid manifest on stdin, so write the skeleton first with `"allowedPaths": []`, then:
|
|
106
106
|
|