session-orchestrator 5.6.0 → 5.7.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 +26 -0
- package/README.md +7 -8
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/wave-scope-commit-guard.mjs +41 -10
- package/package.json +1 -1
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/session-close-backfill.mjs +18 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +2 -0
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -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.7.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.7.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.7.0+codex.20261003172657",
|
|
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.7.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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [5.7.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`scripts/vault-mirror-prune.mjs`** (#1513): applies the vault-mirror retention rules to the existing `40-learnings/` and `50-sessions/` notes. Dry run by default with a JSON manifest (`--manifest`: path, reason, action, target, kept copy, rollup); `--apply` writes the month rollups, then moves archived notes to `90-archive/mirror/<zone>/<same path>` with `status: archived` + `archived-reason`, then renames alias-folder and flat notes into their namespace. Reasons: `duplicate`, `superseded`, `expired`, `metrics-only-session`, `namespace-alias`, `flat-relocate`. Only notes with the mirror's `_generator` marker are touched; nothing is deleted; a second run plans nothing.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **vault-mirror narrative gate for sessions** (#1513): `vault-mirror.quality.min-narrative-chars` now measures the record's free text (`notes`, `narrative`, `summary`) instead of the rendered body with its tables. A record below it gets no note but one row in `50-sessions/<repo>/_rollup-YYYY-MM.md` (action `skipped-metrics-only`; idempotent). `0` disables the gate.
|
|
19
|
+
- **vault-mirror learnings lifecycle** (#1513): the pre-#725 slug variant of a note is updated in place instead of getting a hyphenated twin; a new note whose normalised insight already exists in the namespace is refused (`skipped-duplicate-insight`); an expired learning is never created (`skipped-expired`) and its existing note becomes `status: archived` + `archived-reason: expired`; `--kind learning` also reads the sibling `learnings-archive.jsonl` and marks the notes of archived records (`expired` / `superseded`). `backfill-learnings-from-vault.mjs` falls back to `90-archive/mirror/40-learnings/`.
|
|
20
|
+
- **`session-close-backfill.log` carries events only** (#1513): `skipped-*` outcomes are no longer appended (86 % of the Meta-Vault's 723 KB log); they remain queryable as ledger events.
|
|
21
|
+
- **`sweep-expired-rules` removes expired single-entry rule files** (#1513): a generated rule with one learning and no counter sentence was skipped as `no-counter-sentence` forever; it is now deleted (pair stamped first) once the later of its frontmatter `expires-at` and the learning's store date has passed.
|
|
22
|
+
- **Commit guard names the manifest** (#1514): a parse error and each block header now carry the manifest path (`.claude/wave-scope.json` vs `pkg/.claude/wave-scope.json`), and the "To proceed" hint prints once when both manifests block.
|
|
23
|
+
- **Read failures other than a missing file are reported** (#1216): `countProposalsForWave` and the transcript tail's `readAgentType` now warn on stderr instead of silently returning `0` / `unknown`.
|
|
24
|
+
- **Lint rejects the deprecated locks shim and the deleted `OWNER_YAML_PATH`** (#1234): `no-restricted-imports` with the replacement named in the message.
|
|
25
|
+
- **ADR-0002 verdict corrected to Stay** (#1053): native session↔session and agent→coordinator messaging (Claude Code 2.1.224) removed the one capability Agent Teams added; ADR-0003/0007/0010 and the rules point at the addendum. LM-002a separates Channels from cross-session messaging.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Drift-check Check 7 derives learning keys with the shared `learningKeyOf`** (#1021): a learning with a blank title or an untrimmed type no longer produces a key that differs from the one the rule emitter stamps.
|
|
30
|
+
- Shared helpers instead of private copies: one `tombstoneGap` for the three archive-gap sites (#1505), and `deriveFenceToken`, `learningKeyOf`, `kebab` imported where they were copied (#1021). Behaviour unchanged.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- **H3 Agent Teams test harness** (`hooks/agent-teams-h3-test.sh`, `scripts/spikes/h3-agent-teams/`) — the question it measured is closed (#1061).
|
|
35
|
+
|
|
10
36
|
## [5.6.0] - 2026-10-03
|
|
11
37
|
|
|
12
38
|
### Added
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.**
|
|
@@ -139,15 +139,14 @@ How this compares to other orchestrators, with measured results kept separate fr
|
|
|
139
139
|
|
|
140
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).
|
|
141
141
|
|
|
142
|
-
## Recent highlights (v5.
|
|
142
|
+
## Recent highlights (v5.7.0)
|
|
143
143
|
|
|
144
|
-
Highlights of the v5.
|
|
144
|
+
Highlights of the v5.7.0 line:
|
|
145
145
|
|
|
146
|
-
- **
|
|
147
|
-
- **
|
|
148
|
-
- **
|
|
149
|
-
- **
|
|
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).
|
|
146
|
+
- **Tidy up the vault mirror in place.** `scripts/vault-mirror-prune.mjs` applies the retention rules to existing learning and session notes: dry run with a manifest first, then `--apply` archives duplicates, superseded and expired notes and writes month rollups. Nothing is deleted, and a second run plans nothing (#1513).
|
|
147
|
+
- **Fewer, better vault notes from now on.** A session record without enough free text gets one rollup row instead of a note, an expired learning never becomes a note, and a learning whose insight already exists is refused (#1513).
|
|
148
|
+
- **The commit guard says which manifest it means.** Parse errors and block messages name the manifest file, and the "To proceed" hint prints once (#1514).
|
|
149
|
+
- **Silent failures made visible.** Read errors other than a missing file are now reported instead of turning into `0` or `unknown` (#1216), and lint blocks imports of the deprecated locks shim (#1234).
|
|
151
150
|
|
|
152
151
|
Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
|
|
153
152
|
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -454,7 +454,7 @@ Add a `## Session Config` section to your project's Session Config host file to
|
|
|
454
454
|
| `plan-retro-location` | string | `docs/retro/` | Directory where retrospective documents are saved (relative to project root). |
|
|
455
455
|
| `memory-cleanup-threshold` | integer | `5` | Recommend `/memory-cleanup` after N accumulated session memory files. |
|
|
456
456
|
| `memory-cleanup-soft-limit` | integer | `180` | Hard ceiling on accumulated memory files before the cleanup nudge escalates from soft suggestion to strong recommendation. PRD F2.2 / issue #502. |
|
|
457
|
-
| `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum
|
|
457
|
+
| `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note; below it the session becomes a row in the month rollup `50-sessions/<repo>/_rollup-YYYY-MM.md`. PRD F1.2 / #504, #1513. |
|
|
458
458
|
| `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Set to `0.0` to mirror every learning regardless of confidence. PRD F1.2 / issue #504. |
|
|
459
459
|
| `cold-start.enabled` | boolean | `true` | Master toggle for the cold-start detector. When `false`, no idle-time nudges fire at session-start. PRD F1.3 / issue #500. |
|
|
460
460
|
| `cold-start.nudge-after-hours` | integer | `1` | Hours of wall-clock idle (since the last session-end) before the cold-start detector fires a nudge. PRD F1.3 / issue #500. |
|
package/docs/rule-authoring.md
CHANGED
|
@@ -466,6 +466,16 @@ under four contracts, all of them consequences of the four merge rules above:
|
|
|
466
466
|
`--apply` emits `orchestrator.rules.expiry_sweep_applied` after the writes
|
|
467
467
|
succeed (see `docs/events-schema.md`); a dry run emits nothing.
|
|
468
468
|
|
|
469
|
+
**Single-entry files (#1513).** A generated rule holding exactly ONE learning
|
|
470
|
+
(one provenance pair, no `###` entry, no counter sentence — the per-learning
|
|
471
|
+
shape a consumer's reconciliation engine writes; all 21 generated rules of the
|
|
472
|
+
Meta-Vault on 2026-10-03) is dated by its own frontmatter `expires-at`, the
|
|
473
|
+
date `rule-loader.mjs` acts on. There is no body sentence to keep in agreement,
|
|
474
|
+
so the `no-counter-sentence` refusal does not apply: the file is deleted
|
|
475
|
+
(`reason: 'single-entry-expired'`, pair stamped terminal first) once the LATER
|
|
476
|
+
of that date and the learning's store `expires_at` (when it resolves) has
|
|
477
|
+
passed. A re-stamped learning therefore keeps its rule.
|
|
478
|
+
|
|
469
479
|
## Authoring Examples
|
|
470
480
|
|
|
471
481
|
### (a) Hand-authored always-on rule (no frontmatter)
|
|
@@ -837,7 +837,7 @@ vault-mirror:
|
|
|
837
837
|
|
|
838
838
|
| Field | Type | Default | Description |
|
|
839
839
|
|-------|------|---------|-------------|
|
|
840
|
-
| `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum
|
|
840
|
+
| `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note (#1513). Below it the session becomes one row in `50-sessions/<repo>/_rollup-YYYY-MM.md`; `0` disables the gate. Learnings are not gated by length. Bounds: integer ≥ 0. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504, narrative semantics #1513. |
|
|
841
841
|
| `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Confidence is read from the source learning record. Notes below this threshold are skipped. Set to `0.0` to mirror every learning regardless of confidence. Bounds: `0.0 ≤ value ≤ 1.0`. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504. |
|
|
842
842
|
|
|
843
843
|
**Used by:** `scripts/vault-mirror.mjs`.
|
|
@@ -235,7 +235,7 @@ Quality thresholds applied by `scripts/vault-mirror.mjs` before mirroring a lear
|
|
|
235
235
|
```yaml
|
|
236
236
|
vault-mirror:
|
|
237
237
|
quality:
|
|
238
|
-
min-narrative-chars: 400 # integer ≥ 0 — minimum
|
|
238
|
+
min-narrative-chars: 400 # integer ≥ 0 — minimum session free text (notes/narrative/summary) for an own note; below → month rollup (#1513)
|
|
239
239
|
min-confidence: 0.5 # float 0.0..1.0 — minimum learning confidence to mirror
|
|
240
240
|
```
|
|
241
241
|
|
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.7.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.7.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -154,6 +154,29 @@ function findOverlaps(fenceJsonPath, ourStaged) {
|
|
|
154
154
|
return matches;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** Remediation for an allowedPaths violation, printed once per commit. */
|
|
158
|
+
const ALLOWED_PATHS_HINT = [
|
|
159
|
+
'',
|
|
160
|
+
'These files were likely added by lint-staged eslint --fix / prettier --write.',
|
|
161
|
+
'To proceed:',
|
|
162
|
+
' 1) git restore --staged <path> # for each foreign path',
|
|
163
|
+
' 2) git commit # retry',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A manifest path for messages: relative to the repo root, the absolute path
|
|
168
|
+
* when it does not lie beneath it (#1514.2 — two candidate manifests, so a
|
|
169
|
+
* message must say which one it means).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} repoRoot
|
|
172
|
+
* @param {string} scopePath
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
function manifestLabel(repoRoot, scopePath) {
|
|
176
|
+
const rel = relative(repoRoot, scopePath);
|
|
177
|
+
return rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? scopePath : rel;
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
/** mtime of a file in ms, or `null` when it cannot be stat'ed. */
|
|
158
181
|
function mtimeMsOf(file) {
|
|
159
182
|
try {
|
|
@@ -183,11 +206,14 @@ function mtimeMsOf(file) {
|
|
|
183
206
|
* without pattern matching, since a `../`-prefixed path would match a glob
|
|
184
207
|
* like `**\/*.mjs` and enforce-scope denies every write outside its root
|
|
185
208
|
* (REQ-04).
|
|
186
|
-
* @param {string} p.scopeRel manifest path for messages, repo-relative
|
|
209
|
+
* @param {string} p.scopeRel manifest path for messages, repo-relative (absolute
|
|
210
|
+
* when outside the repo root) — `manifestLabel`
|
|
187
211
|
* @param {Set<string>} p.ownIds this process's session ids
|
|
188
212
|
* @param {number|null} p.scopeMtimeMs
|
|
189
213
|
* @param {number|null} p.sessionAge ms since the newest session clock
|
|
190
|
-
* @returns {{action: 'pass'|'report'|'block', lines: string[]}}
|
|
214
|
+
* @returns {{action: 'pass'|'report'|'block', lines: string[], hint?: string[]}}
|
|
215
|
+
* `hint` = the remediation block; `main()` prints it ONCE after every
|
|
216
|
+
* manifest was judged, so two blocking manifests do not repeat it (#1514.3).
|
|
191
217
|
*/
|
|
192
218
|
function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
193
219
|
if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
|
|
@@ -218,14 +244,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeR
|
|
|
218
244
|
return {
|
|
219
245
|
action: 'block',
|
|
220
246
|
lines: [
|
|
221
|
-
|
|
247
|
+
`✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths (${scopeRel}):`,
|
|
222
248
|
...violations.map((v) => ` - ${v}`),
|
|
223
|
-
'',
|
|
224
|
-
'These files were likely added by lint-staged eslint --fix / prettier --write.',
|
|
225
|
-
'To proceed:',
|
|
226
|
-
' 1) git restore --staged <path> # for each foreign path',
|
|
227
|
-
' 2) git commit # retry',
|
|
228
249
|
],
|
|
250
|
+
hint: ALLOWED_PATHS_HINT,
|
|
229
251
|
};
|
|
230
252
|
}
|
|
231
253
|
|
|
@@ -354,15 +376,22 @@ async function main() {
|
|
|
354
376
|
const stagedFiles = stagedOutput.split('\n').filter(Boolean);
|
|
355
377
|
|
|
356
378
|
let blocked = false;
|
|
379
|
+
/** @type {string[]|null} printed once after the loop (#1514.3) */
|
|
380
|
+
let hint = null;
|
|
381
|
+
const flushHint = () => {
|
|
382
|
+
if (hint !== null) for (const line of hint) process.stderr.write(`${line}\n`);
|
|
383
|
+
};
|
|
357
384
|
for (const { root, scopePathOf } of judgements) {
|
|
358
385
|
const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
|
|
359
386
|
const scopePath = located.path ?? located.foreignPath;
|
|
360
387
|
if (!scopePath) continue;
|
|
388
|
+
const scopeRel = manifestLabel(repoRoot, scopePath);
|
|
361
389
|
let scope;
|
|
362
390
|
try {
|
|
363
391
|
scope = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
364
392
|
} catch (err) {
|
|
365
|
-
process.stderr.write(`wave-scope-commit-guard: failed to parse
|
|
393
|
+
process.stderr.write(`wave-scope-commit-guard: failed to parse ${scopeRel}: ${err.message}\n`);
|
|
394
|
+
flushHint();
|
|
366
395
|
process.exit(1);
|
|
367
396
|
}
|
|
368
397
|
|
|
@@ -370,14 +399,16 @@ async function main() {
|
|
|
370
399
|
scope: scope !== null && typeof scope === 'object' ? scope : {},
|
|
371
400
|
stagedFiles,
|
|
372
401
|
scopePathOf,
|
|
373
|
-
scopeRel
|
|
402
|
+
scopeRel,
|
|
374
403
|
ownIds,
|
|
375
404
|
scopeMtimeMs: mtimeMsOf(scopePath),
|
|
376
405
|
sessionAge: sessionAgeMs(root),
|
|
377
406
|
});
|
|
378
407
|
for (const line of verdict.lines) process.stderr.write(`${line}\n`);
|
|
408
|
+
if (verdict.hint) hint = verdict.hint;
|
|
379
409
|
if (verdict.action === 'block') blocked = true;
|
|
380
410
|
}
|
|
411
|
+
flushHint();
|
|
381
412
|
if (blocked) process.exit(1);
|
|
382
413
|
|
|
383
414
|
// -------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
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",
|
|
@@ -42,8 +42,8 @@ import path from 'node:path';
|
|
|
42
42
|
import { parseArgs } from 'node:util';
|
|
43
43
|
|
|
44
44
|
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
|
-
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
|
|
46
|
-
import { ARCHIVE_DIR_NAME
|
|
45
|
+
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst, tombstoneGap } from './lib/events.mjs';
|
|
46
|
+
import { ARCHIVE_DIR_NAME } from './lib/events-schema.mjs';
|
|
47
47
|
import { getProjectDir } from './lib/platform.mjs';
|
|
48
48
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
49
49
|
import { isLockLive, readLock } from './lib/session-lock.mjs';
|
|
@@ -163,7 +163,8 @@ export function planSessions({ repoRoot }) {
|
|
|
163
163
|
* not one of the sources read (#1498). Pruned history used to shrink the plan
|
|
164
164
|
* silently; non-zero `historyGaps` says the candidate count is a floor.
|
|
165
165
|
*
|
|
166
|
-
* The check is `
|
|
166
|
+
* The check is `tombstoneGap` (`scripts/lib/events.mjs`), the one shared with
|
|
167
|
+
* `readEventsWithRotations` rule 1:
|
|
167
168
|
* resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
|
|
168
169
|
* file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
|
|
169
170
|
* counts as a gap too — existence is not reading (#1423).
|
|
@@ -203,10 +204,7 @@ function planWithGaps({ repoRoot }) {
|
|
|
203
204
|
for (const source of [...sources].reverse()) {
|
|
204
205
|
forEachJsonlRecord(source.path, (ev) => {
|
|
205
206
|
if (!ev || typeof ev !== 'object') return;
|
|
206
|
-
if (ev
|
|
207
|
-
&& !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
|
|
208
|
-
historyGaps += 1;
|
|
209
|
-
}
|
|
207
|
+
if (tombstoneGap(ev, archiveDir, read) !== null) historyGaps += 1;
|
|
210
208
|
if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
|
|
211
209
|
if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
|
|
212
210
|
else if (ev.event === SESSION_ENDED) {
|
|
@@ -837,6 +837,19 @@ export async function main(argv = [], deps = {}) {
|
|
|
837
837
|
const archiveIds = idsInStore(archiveAbs);
|
|
838
838
|
const backups = backupPaths(storeAbs).map((p) => ({ path: p, ids: idsInStore(p) }));
|
|
839
839
|
const { notes, unreadable } = indexVaultNotes(learningsAbs);
|
|
840
|
+
// #1513: `vault-mirror-prune --apply` moves expired/duplicate/superseded
|
|
841
|
+
// learning notes to `90-archive/mirror/<subdir>/…` with their bytes intact.
|
|
842
|
+
// They are still evidence for this restore tool — but only as a FALLBACK:
|
|
843
|
+
// the archive holds the non-canonical twins of live notes, and searching
|
|
844
|
+
// both at once would turn every such learning into an ambiguous match.
|
|
845
|
+
const archivedAbs = resolve(vaultDir, '90-archive', 'mirror', vaultSubdir);
|
|
846
|
+
const archivedNotes = [];
|
|
847
|
+
if (archivedAbs !== learningsAbs && existsSync(archivedAbs)) {
|
|
848
|
+
const archived = indexVaultNotes(archivedAbs);
|
|
849
|
+
for (const n of archived.notes) n.relPath = join('90-archive', 'mirror', vaultSubdir, n.relPath);
|
|
850
|
+
archivedNotes.push(...archived.notes);
|
|
851
|
+
unreadable.push(...archived.unreadable);
|
|
852
|
+
}
|
|
840
853
|
for (const u of unreadable) process.stderr.write(`backfill-learnings-from-vault: WARN unreadable vault note ${u}\n`);
|
|
841
854
|
|
|
842
855
|
const repoHint = basename(root);
|
|
@@ -879,12 +892,10 @@ export async function main(argv = [], deps = {}) {
|
|
|
879
892
|
|
|
880
893
|
orphanIndex += 1;
|
|
881
894
|
const keySubjectSlug = prov.learningKey ? prov.learningKey.split('/').slice(1).join('/') : null;
|
|
882
|
-
const {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
repoHint,
|
|
887
|
-
});
|
|
895
|
+
const query = { subject: prov.subject, keySlug: keySubjectSlug, learningId: id, repoHint };
|
|
896
|
+
let located = locateNote(notes, query);
|
|
897
|
+
if (!located.note && !located.ambiguous && archivedNotes.length > 0) located = locateNote(archivedNotes, query);
|
|
898
|
+
const { note, strategy, ambiguous } = located;
|
|
888
899
|
|
|
889
900
|
const { record, fidelity, conflicts, crossChecks, validates, validationError } = reconstructRecord({
|
|
890
901
|
rule: prov,
|
|
@@ -202,7 +202,11 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
|
202
202
|
const days = (nowMs - latestMs) / MS_PER_DAY;
|
|
203
203
|
return days > 0 ? days : 0;
|
|
204
204
|
} catch {
|
|
205
|
-
//
|
|
205
|
+
// Not the missing/unreadable-ledger path: readCanonicalSessions already
|
|
206
|
+
// returns [] for both (ENOENT silently, any other read failure with a
|
|
207
|
+
// stderr WARN — sessions-canonical.mjs § #1188), handled by the
|
|
208
|
+
// `records.length === 0` branch above. This catch only absorbs an
|
|
209
|
+
// unexpected throw (e.g. a non-string repoRoot) ⇒ maximally stale.
|
|
206
210
|
return STALENESS_CAP_DAYS;
|
|
207
211
|
}
|
|
208
212
|
}
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -578,6 +578,60 @@ function discoverArchives(logPath) {
|
|
|
578
578
|
return { archives, legacy: present.map((i) => `${logPath}.${i}`), ringHoles, unindexed };
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/**
|
|
582
|
+
* The one tombstone-to-archive match (#1505 5c) — shared by
|
|
583
|
+
* {@link readEventsWithRotations} rule 1, {@link scanEventsBackwards}, and
|
|
584
|
+
* `planWithGaps` in `scripts/backfill-abandoned-sessions.mjs`, which used to
|
|
585
|
+
* carry three hand-kept copies of it.
|
|
586
|
+
*
|
|
587
|
+
* #1411 — the tombstone resolves against THIS ledger's own `_archive/`, by
|
|
588
|
+
* BASENAME, never against the absolute value it stores. The writer keeps
|
|
589
|
+
* `archived_as` absolute (`events-rotation.mjs` joins the repo root) and that
|
|
590
|
+
* value stays in the record as PROVENANCE. It is not a lookup key: a moved
|
|
591
|
+
* checkout, a clone, or a sibling git worktree — routine here — makes every
|
|
592
|
+
* tombstone name a path that does not exist in THIS tree, so an exact
|
|
593
|
+
* comparison reported a phantom `missing-archive` for an archive sitting right
|
|
594
|
+
* beside the active file. Basename, not `realpath`: realpath cannot resolve a
|
|
595
|
+
* path that no longer exists, which IS the failure mode.
|
|
596
|
+
*
|
|
597
|
+
* ORDER (why the absolute value has no second chance): the sibling answer is
|
|
598
|
+
* consulted first, and an absolute hit would only be trustworthy while it
|
|
599
|
+
* pointed INSIDE this ledger's own archive dir — but any such path resolves to
|
|
600
|
+
* exactly the sibling path already tested, so once the sibling misses, an
|
|
601
|
+
* absolute hit can ONLY be a still-present OLD checkout. Honouring it would
|
|
602
|
+
* validate THIS ledger against a FOREIGN repo's archive: a silent false
|
|
603
|
+
* negative, worse than the phantom gap.
|
|
604
|
+
*
|
|
605
|
+
* #1423 — THREE outcomes, not two: read (`null`), present-but-unread
|
|
606
|
+
* (`unindexed-archive`), gone (`missing-archive`). The middle one used to be
|
|
607
|
+
* folded into the first by `existsSync`, so a tombstone whose archive had been
|
|
608
|
+
* RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its records
|
|
609
|
+
* were absent.
|
|
610
|
+
*
|
|
611
|
+
* @param {unknown} record a parsed ledger line
|
|
612
|
+
* @param {string} ownArchiveDir this ledger's `_archive/`, built exactly as
|
|
613
|
+
* `discoverArchives` builds it so a read archive matches `readSet` by string
|
|
614
|
+
* @param {Set<string>} readSet the paths this read actually took as sources
|
|
615
|
+
* @returns {{kind: 'unindexed-archive'|'missing-archive', path: string,
|
|
616
|
+
* archived_as: string, first_ts: string|null, last_ts: string|null}|null}
|
|
617
|
+
* `null` when `record` is not a rotation tombstone, names no archive, or its
|
|
618
|
+
* archive was read.
|
|
619
|
+
*/
|
|
620
|
+
export function tombstoneGap(record, ownArchiveDir, readSet) {
|
|
621
|
+
if (record?.event !== ROTATION_EVENT) return null;
|
|
622
|
+
const target = record.archived_as;
|
|
623
|
+
if (typeof target !== 'string' || target.length === 0) return null;
|
|
624
|
+
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
625
|
+
if (readSet.has(sibling)) return null;
|
|
626
|
+
return {
|
|
627
|
+
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
628
|
+
path: sibling,
|
|
629
|
+
archived_as: target,
|
|
630
|
+
first_ts: record.first_ts ?? null,
|
|
631
|
+
last_ts: record.last_ts ?? null,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
581
635
|
/**
|
|
582
636
|
* Read the events ledger ACROSS rotation boundaries — active file plus every
|
|
583
637
|
* archive still on disk — in time order, reporting what is missing instead of
|
|
@@ -660,44 +714,14 @@ export function readEventsWithRotations(repoRoot, opts = {}) {
|
|
|
660
714
|
// `onDisk` still match by string.
|
|
661
715
|
const ownArchiveDir = path.join(path.dirname(activePath), ARCHIVE_DIR_NAME);
|
|
662
716
|
|
|
663
|
-
// Rule 1 — every rotation tombstone must still point at a file
|
|
717
|
+
// Rule 1 — every rotation tombstone must still point at a file (resolution
|
|
718
|
+
// and the three outcomes: {@link tombstoneGap}).
|
|
664
719
|
for (const source of sources) {
|
|
665
720
|
for (const record of source.records) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (typeof target !== 'string' || target.length === 0) continue;
|
|
669
|
-
// #1411 — resolve the tombstone against THIS ledger's own `_archive/`,
|
|
670
|
-
// by BASENAME, and never against the absolute value it stores.
|
|
671
|
-
//
|
|
672
|
-
// The writer keeps `archived_as` absolute (`events-rotation.mjs` joins
|
|
673
|
-
// the repo root) and that value stays in the record as PROVENANCE. It is
|
|
674
|
-
// not a lookup key: a moved checkout, a clone, or a sibling git worktree
|
|
675
|
-
// — routine here — makes every tombstone name a path that does not exist
|
|
676
|
-
// in THIS tree, so the old exact comparison reported a phantom
|
|
677
|
-
// `missing-archive` for an archive sitting right beside the active file.
|
|
678
|
-
// Basename, not `realpath`: realpath cannot resolve a path that no longer
|
|
679
|
-
// exists, which IS the failure mode.
|
|
680
|
-
//
|
|
681
|
-
// ORDER (why the absolute value has no second chance): the sibling answer
|
|
682
|
-
// is consulted first, and an absolute hit would only be trustworthy while
|
|
683
|
-
// it pointed INSIDE this ledger's own archive dir — but any such path
|
|
684
|
-
// resolves to exactly the sibling path already tested, so once the
|
|
685
|
-
// sibling misses, an absolute hit can ONLY be a still-present OLD
|
|
686
|
-
// checkout. Honouring it would validate THIS ledger against a FOREIGN
|
|
687
|
-
// repo's archive: a silent false negative, worse than the phantom gap.
|
|
688
|
-
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
689
|
-
if (onDisk.has(sibling)) continue;
|
|
721
|
+
const gap = tombstoneGap(record, ownArchiveDir, onDisk);
|
|
722
|
+
if (gap === null) continue;
|
|
690
723
|
gaps.push({
|
|
691
|
-
|
|
692
|
-
// (here), gone (below). The middle one used to be silently folded into
|
|
693
|
-
// the first by `existsSync`, so a tombstone whose archive had been
|
|
694
|
-
// RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its
|
|
695
|
-
// records were absent from `events`.
|
|
696
|
-
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
697
|
-
path: sibling,
|
|
698
|
-
archived_as: target,
|
|
699
|
-
first_ts: record.first_ts ?? null,
|
|
700
|
-
last_ts: record.last_ts ?? null,
|
|
724
|
+
...gap,
|
|
701
725
|
lines: record.lines ?? null,
|
|
702
726
|
size_before: record.size_before ?? null,
|
|
703
727
|
reported_by: source.path,
|
|
@@ -866,8 +890,7 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
866
890
|
* LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
|
|
867
891
|
* async SessionStart hook, so a concurrent append can land first and push it
|
|
868
892
|
* to line 2. Called BEFORE the caller's `filter`, which names another event
|
|
869
|
-
* and would otherwise hide the cut.
|
|
870
|
-
* ledger's own `_archive/`, never by the absolute provenance value (#1411).
|
|
893
|
+
* and would otherwise hide the cut. Matched by {@link tombstoneGap}.
|
|
871
894
|
*/
|
|
872
895
|
const noteCut = (line, source) => {
|
|
873
896
|
if (!line.includes(ROTATION_EVENT)) return;
|
|
@@ -877,18 +900,8 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
877
900
|
} catch {
|
|
878
901
|
return; // counted by `consume` like every other malformed line
|
|
879
902
|
}
|
|
880
|
-
const
|
|
881
|
-
if (
|
|
882
|
-
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
883
|
-
if (listed.has(sibling)) return;
|
|
884
|
-
gaps.push({
|
|
885
|
-
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
886
|
-
path: sibling,
|
|
887
|
-
archived_as: target,
|
|
888
|
-
first_ts: record.first_ts ?? null,
|
|
889
|
-
last_ts: record.last_ts ?? null,
|
|
890
|
-
reported_by: source.path,
|
|
891
|
-
});
|
|
903
|
+
const gap = tombstoneGap(record, ownArchiveDir, listed);
|
|
904
|
+
if (gap !== null) gaps.push({ ...gap, reported_by: source.path });
|
|
892
905
|
};
|
|
893
906
|
|
|
894
907
|
/** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
|
package/scripts/lib/io.mjs
CHANGED
|
@@ -946,16 +946,21 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
946
946
|
* neither may mask the original error.
|
|
947
947
|
*
|
|
948
948
|
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
949
|
+
* THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
|
|
950
|
+
* `rg -n "atomicWriteWithBackup\(" scripts hooks skills --glob '*.mjs'`):
|
|
951
|
+
* `vault-status/board-writer.mjs#writeBoard` (`backup: false`),
|
|
952
|
+
* `reconcile/rule-expiry-sweep.mjs` (no `backup`, i.e. `false`) and
|
|
953
|
+
* {@link writeJsonAtomicSync}, which carries 14 further call-sites across 11
|
|
954
|
+
* files behind it (`rg -n "writeJsonAtomicSync\("` over the same scope, minus
|
|
955
|
+
* the definition and `host-identity.mjs`'s private `_writeJsonAtomicSync`
|
|
956
|
+
* copy, which does NOT route through this helper). That last one is the load-bearing
|
|
952
957
|
* evidence — the previous revision of this note recorded ONE call-site and
|
|
953
958
|
* concluded the signature was unproven, while the function with an identical
|
|
954
959
|
* body sat 60 lines above in this same file, unmigrated. The cheapest possible
|
|
955
960
|
* migration going unmade is not a neutral fact about a helper: it is the
|
|
956
961
|
* measurement that the helper is not paying for itself.
|
|
957
962
|
*
|
|
958
|
-
* What the
|
|
963
|
+
* What the `writeJsonAtomicSync` call-site does NOT prove: it passes
|
|
959
964
|
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
960
965
|
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
961
966
|
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
|
|
186
186
|
import { tokenize } from './affinity.mjs';
|
|
187
187
|
import { readLearnings } from './io.mjs';
|
|
188
|
-
import {
|
|
188
|
+
import { learningKeyOf } from './kebab.mjs';
|
|
189
189
|
import { normalizeDialects } from './schema.mjs';
|
|
190
190
|
|
|
191
191
|
// ---------------------------------------------------------------------------
|
|
@@ -441,27 +441,18 @@ export function emptyPools() {
|
|
|
441
441
|
/**
|
|
442
442
|
* Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
|
|
443
443
|
*
|
|
444
|
-
* Returns `null` when either half is unusable
|
|
445
|
-
*
|
|
446
|
-
*
|
|
444
|
+
* Returns `null` when either half is unusable (non-record input, blank
|
|
445
|
+
* type/title/subject, or an all-symbol title that kebabs to '') — delegates to
|
|
446
|
+
* the shared `kebab.mjs::learningKeyOf` (#1021) so the key space cannot fork
|
|
447
|
+
* from the provenance checker's. An unkeyable record simply does not
|
|
448
|
+
* participate in the exact-key pass. It still seeds and
|
|
447
449
|
* joins pools: a missing title is not a reason to hide a learning from dedupe.
|
|
448
450
|
*
|
|
449
451
|
* @param {unknown} record
|
|
450
452
|
* @returns {string|null}
|
|
451
453
|
*/
|
|
452
454
|
export function learningKey(record) {
|
|
453
|
-
|
|
454
|
-
const type = typeof record.type === 'string' ? record.type.trim() : '';
|
|
455
|
-
const titleOrSubject =
|
|
456
|
-
(typeof record.title === 'string' && record.title.trim() !== '' ? record.title : '') ||
|
|
457
|
-
(typeof record.subject === 'string' && record.subject.trim() !== '' ? record.subject : '');
|
|
458
|
-
if (type === '' || titleOrSubject === '') return null;
|
|
459
|
-
const slug = kebab(titleOrSubject);
|
|
460
|
-
// `kebab` may legally return '' (all-symbol input) — an empty slug is not an
|
|
461
|
-
// identity, so such a record is unkeyable rather than colliding with every
|
|
462
|
-
// other empty-slug record of the same type.
|
|
463
|
-
if (slug === '') return null;
|
|
464
|
-
return `${type}/${slug}`;
|
|
455
|
+
return learningKeyOf(record);
|
|
465
456
|
}
|
|
466
457
|
|
|
467
458
|
/**
|
|
@@ -413,7 +413,10 @@ export async function appendProposal({
|
|
|
413
413
|
* Reads proposals.jsonl without acquiring the lock — suitable for diagnostics
|
|
414
414
|
* and reads where eventual consistency is acceptable.
|
|
415
415
|
*
|
|
416
|
-
* Returns 0 when the file does not exist
|
|
416
|
+
* Returns 0 when the file does not exist (silently — `countWaveLines` maps
|
|
417
|
+
* ENOENT to 0). Any other read failure also returns 0, but with a stderr WARN
|
|
418
|
+
* so a 0 caused by an unreadable file is distinguishable from an empty queue
|
|
419
|
+
* (#1216).
|
|
417
420
|
*
|
|
418
421
|
* @param {object} opts
|
|
419
422
|
* @param {string} opts.repoRoot
|
|
@@ -424,7 +427,11 @@ export async function countProposalsForWave({ repoRoot, waveId }) {
|
|
|
424
427
|
const jsonlPath = jsonlPathFor(repoRoot);
|
|
425
428
|
try {
|
|
426
429
|
return countWaveLines(jsonlPath, waveId);
|
|
427
|
-
} catch {
|
|
430
|
+
} catch (err) {
|
|
431
|
+
process.stderr.write(
|
|
432
|
+
`[memory-proposals] WARN: countProposalsForWave: ` +
|
|
433
|
+
`${err?.code ?? '?'}: ${err?.message ?? String(err)} — returning 0\n`,
|
|
434
|
+
);
|
|
428
435
|
return 0;
|
|
429
436
|
}
|
|
430
437
|
}
|