session-orchestrator 3.23.0 → 3.24.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/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
* G1 tool filter — only Edit/Write/MultiEdit are gated
|
|
10
10
|
* G2 file_path present + string
|
|
11
11
|
* G3 wave-scope.json exists
|
|
12
|
-
* G3b (#1123) the manifest belongs to THIS session — a manifest that
|
|
12
|
+
* G3b (#1123, #1194) the manifest belongs to THIS session — a manifest that
|
|
13
13
|
* PROVABLY names another live session in this shared working copy is not
|
|
14
14
|
* ours to enforce; allow + emit one event. Runs AFTER the parse so a
|
|
15
|
-
* corrupt manifest still fails closed.
|
|
15
|
+
* corrupt manifest still fails closed. Identity is PROCESS-LOCAL only
|
|
16
|
+
* (hook payload + CLAUDE_CODE_SESSION_ID), never the repo-global
|
|
17
|
+
* `session.lock` — see the gate's own block for why.
|
|
16
18
|
* G4 path-guard gate enabled
|
|
17
19
|
* G5 enforcement != "off"
|
|
18
20
|
* G5b (#792) allowlist-first: an EXPLICIT absolute allowedPaths entry that
|
|
@@ -102,8 +104,10 @@ let readJson;
|
|
|
102
104
|
let classifyEmptyScope;
|
|
103
105
|
let suggestForEmptyScope;
|
|
104
106
|
let sessionStartedAtMs;
|
|
105
|
-
// #1123 — "is this manifest even mine?" (G3b).
|
|
106
|
-
|
|
107
|
+
// #1123 — "is this manifest even mine?" (G3b). Process-local identity only
|
|
108
|
+
// (#1194): the repo-global `session.lock` tier is shared by every session in the
|
|
109
|
+
// checkout and would classify a peer's manifest as ours.
|
|
110
|
+
let readProcessLocalSessionIds;
|
|
107
111
|
let classifyManifestSession;
|
|
108
112
|
|
|
109
113
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
@@ -180,7 +184,7 @@ async function bootstrap() {
|
|
|
180
184
|
({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
|
|
181
185
|
({ readJson } = modules.common);
|
|
182
186
|
({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
|
|
183
|
-
({
|
|
187
|
+
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
async function main() {
|
|
@@ -251,13 +255,38 @@ async function main() {
|
|
|
251
255
|
// without a `session` field is `'unknown'` and stays enforced: only what is
|
|
252
256
|
// PROVABLY foreign is treated as foreign.
|
|
253
257
|
//
|
|
258
|
+
// IDENTITY TIER (#1194): `readProcessLocalSessionIds` — the hook payload and
|
|
259
|
+
// `CLAUDE_CODE_SESSION_ID` — and deliberately NOT `readOwnSessionIds`, whose
|
|
260
|
+
// third tier is the repo-global `session.lock`. That lock is ONE file shared
|
|
261
|
+
// by every session in the checkout, so unioning it made a peer's manifest
|
|
262
|
+
// match a peer-written lock id and classify as `'own'`: Gate 7 then denied the
|
|
263
|
+
// second session's legitimate writes — the exact lockout G3b exists to end.
|
|
264
|
+
// A better signal REPLACES a worse one (`host-resources.md` § HR-102).
|
|
265
|
+
//
|
|
266
|
+
// The trade this buys, accepted rather than hidden: a session that wrote a
|
|
267
|
+
// PEER's id into its OWN manifest (it lost the `bootstrapLock()` race, so
|
|
268
|
+
// `sessionAttribution()` handed it the peer's lock id) now reads `'foreign'`
|
|
269
|
+
// and its own guard stands down. The defense is on the WRITER side — the
|
|
270
|
+
// manifest writer must omit the `session` keys when the lock does not name it
|
|
271
|
+
// (`skills/wave-executor/wave-loop.md` § Scope Manifest, "Verify the binding
|
|
272
|
+
// names YOU before you write it") — not on this reader.
|
|
273
|
+
//
|
|
274
|
+
// CEILING (BV-004): on a harness that exports no session env var and puts no
|
|
275
|
+
// `session_id` in the hook payload (Codex CLI, Cursor today), both tiers are
|
|
276
|
+
// empty, so G3b is permanently `'unknown'` = enforce = pre-#1123 behaviour
|
|
277
|
+
// there. Revisit when Codex/Cursor hook payloads carry a session id.
|
|
278
|
+
//
|
|
254
279
|
// ACCEPTED RESIDUAL, named rather than hidden: `session` is a plain field in a
|
|
255
280
|
// file any process in this working copy can write, so writing a foreign id
|
|
256
281
|
// into it switches this guard off for that manifest. That is the SAME power
|
|
257
282
|
// `enforcement: "off"` already grants in the same file — this gate adds no new
|
|
258
283
|
// authority, and the manifest is the coordinator's own artefact either way.
|
|
259
284
|
{
|
|
260
|
-
|
|
285
|
+
// `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
|
|
286
|
+
// string[], and `classifyManifestSession` does `ownIds instanceof Set ?
|
|
287
|
+
// ownIds : new Set()` — a bare array would silently become EMPTY, making
|
|
288
|
+
// every manifest read `unknown`.
|
|
289
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
261
290
|
const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
|
|
262
291
|
if (verdict === 'foreign') {
|
|
263
292
|
// Observability only, and deliberately NOT emitWarn: this branch is hit on
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v3.24.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 v3.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v3.24.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-session-end.mjs
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
*
|
|
12
12
|
* JSONL format (`.orchestrator/metrics/events.jsonl`):
|
|
13
13
|
* {"timestamp":<ISO>,"event":"orchestrator.session.ended","session_id":"...","semantic_session_id":"...","reason":"<reason>","duration_ms":<int>}
|
|
14
|
-
* (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
|
|
14
|
+
* (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
|
|
15
|
+
* `duration_ms` likewise: it is written ONLY when the ending session IS the
|
|
16
|
+
* one `current-session.json` records AND a start timestamp parsed. A
|
|
17
|
+
* fabricated `0` reads as a measured zero-length session and is
|
|
18
|
+
* indistinguishable from one — 1082 of 1498 fleet records (72,2 %) carried
|
|
19
|
+
* exactly that zero, measured 2026-09-02. Omit, never fabricate.)
|
|
15
20
|
*
|
|
16
21
|
* Exit codes: 0 always (informational hook — must never block session teardown).
|
|
17
22
|
* stdin: optional JSON { hook_event_name:"SessionEnd", session_id?, reason?, cwd? }.
|
|
@@ -46,6 +51,7 @@ import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.m
|
|
|
46
51
|
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
47
52
|
import { flush } from '../scripts/lib/telemetry/sync.mjs';
|
|
48
53
|
import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
|
|
54
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
49
55
|
|
|
50
56
|
// ---------------------------------------------------------------------------
|
|
51
57
|
// stdin reading (inline — SessionEnd hooks exit 0 always, never deny)
|
|
@@ -84,6 +90,9 @@ async function readStdinJson() {
|
|
|
84
90
|
* `.orchestrator/current-session.json` (written by on-session-start.mjs).
|
|
85
91
|
* duration_ms is only computed when the ENDING session is the one recorded in
|
|
86
92
|
* current-session.json — never fabricated for a mismatched / unknown session.
|
|
93
|
+
* Not measurable ⇒ `durationMs: null`, and the caller then OMITS the key
|
|
94
|
+
* entirely (#1193 W5 F1, the last site of the omit-never-fabricate class this
|
|
95
|
+
* session removed from `session.stopped` and `agent.stopped`).
|
|
87
96
|
*
|
|
88
97
|
* #1091 / Kanevry#66 — WRITER/READER SYMMETRY. `on-session-start.mjs`
|
|
89
98
|
* (`resolveSessionId`, :316-317) accepts a stdin raw id only when
|
|
@@ -114,14 +123,42 @@ async function readStdinJson() {
|
|
|
114
123
|
* session therefore resolves `semanticSessionId: null` rather than inheriting
|
|
115
124
|
* another live session's backfill identity.
|
|
116
125
|
*
|
|
126
|
+
* #1193 W4a review F-A — `isRecordedSession` is computed from the RAW stdin
|
|
127
|
+
* UUID, NEVER from the resolved `sessionId`. The old order was
|
|
128
|
+
* `if (sessionId === null) sessionId = recordedId;` followed by
|
|
129
|
+
* `sessionId === recordedId`, which is SELF-FULFILLING: whenever stdin carried
|
|
130
|
+
* no session_id (or a non-UUID one), the fallback assigned the file's own id
|
|
131
|
+
* and the compare then trivially succeeded. Reproduced twice 2026-09-02 —
|
|
132
|
+
* peer-owned `current-session.json` (`last_wave: 3`) plus stdin
|
|
133
|
+
* `{"reason":"other"}` emitted `wave.completed` for the PEER's wave and wrote
|
|
134
|
+
* `last_wave_completed: 3` into the PEER's file, after which the peer's own
|
|
135
|
+
* SessionEnd stayed silent. The same vacuous predicate had always gated
|
|
136
|
+
* `durationMs` and `semanticSessionId`, so the #863 guard was hollow on that
|
|
137
|
+
* path too. One root fix (BV-003) for all three consumers: a `null` raw id
|
|
138
|
+
* means "not attestable" ⇒ `false`.
|
|
139
|
+
*
|
|
140
|
+
* WHY `sessionId` STILL FALLS BACK while the predicate does not. The two are
|
|
141
|
+
* deliberately asymmetric. `sessionId` is the hook's ACTOR identity — the id
|
|
142
|
+
* `deregisterSelf()` and the lock-release ownership compare use, and its
|
|
143
|
+
* current-session.json fallback is a named contract pinned by
|
|
144
|
+
* `tests/hooks/on-session-end.test.mjs` ("falls back to current-session.json
|
|
145
|
+
* session_id when stdin omits it"). `isRecordedSession` is an OWNERSHIP
|
|
146
|
+
* ASSERTION about a repo-global file; an assertion may never be derived from
|
|
147
|
+
* the very value it is asserting about. So the fallback stays for the emitted
|
|
148
|
+
* `session_id` field, and every claim that speaks FOR the recorded session
|
|
149
|
+
* (`duration_ms`, `semantic_session_id`, the final `wave.completed`) is gated
|
|
150
|
+
* on the strict raw compare instead. Precedent: `hooks/on-stop.mjs`
|
|
151
|
+
* `resolveStopDuration()`, which refuses the resolved id for the same reason.
|
|
152
|
+
*
|
|
117
153
|
* @param {object|null} input
|
|
118
154
|
* @param {string} projectRoot
|
|
119
|
-
* @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number}>}
|
|
155
|
+
* @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number|null, isRecordedSession: boolean, rawStdinId: string|null}>}
|
|
120
156
|
*/
|
|
121
157
|
async function resolveSession(input, projectRoot) {
|
|
122
158
|
const fromStdin = input?.session_id ?? input?.sessionId ?? null;
|
|
123
159
|
// UUID-only, exactly as the writer decides it (see the docblock above).
|
|
124
|
-
|
|
160
|
+
const rawStdinId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
|
|
161
|
+
let sessionId = rawStdinId;
|
|
125
162
|
|
|
126
163
|
let recordedId = null;
|
|
127
164
|
let semanticSessionId = null;
|
|
@@ -144,13 +181,17 @@ async function resolveSession(input, projectRoot) {
|
|
|
144
181
|
}
|
|
145
182
|
} catch { /* missing or unparseable is fine */ }
|
|
146
183
|
|
|
184
|
+
// Actor-identity fallback ONLY — see the docblock's asymmetry note. This
|
|
185
|
+
// value must NEVER feed the ownership predicate below.
|
|
147
186
|
if (sessionId === null) sessionId = recordedId;
|
|
148
187
|
|
|
149
|
-
// Only trust the recorded start time when the ending session IS the recorded
|
|
150
|
-
|
|
188
|
+
// Only trust the recorded start time when the ending session IS the recorded
|
|
189
|
+
// one — decided on the RAW stdin id, so an absent/non-UUID id is `false`
|
|
190
|
+
// rather than self-fulfilling (F-A).
|
|
191
|
+
const isRecordedSession = rawStdinId !== null && rawStdinId === recordedId;
|
|
151
192
|
const durationMs = startedAtMs !== null && isRecordedSession
|
|
152
193
|
? Math.max(0, Date.now() - startedAtMs)
|
|
153
|
-
:
|
|
194
|
+
: null;
|
|
154
195
|
|
|
155
196
|
// #863 defect (c) — same guard as durationMs above: only surface the
|
|
156
197
|
// recorded semantic id when THIS ending session is genuinely the one
|
|
@@ -158,7 +199,16 @@ async function resolveSession(input, projectRoot) {
|
|
|
158
199
|
// contamination scenario this closes.
|
|
159
200
|
const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
|
|
160
201
|
|
|
161
|
-
|
|
202
|
+
// `rawStdinId` is returned so a LATER read of current-session.json can
|
|
203
|
+
// re-verify ownership against the same identity (W4c Q1-LOW-TOCTOU) instead
|
|
204
|
+
// of trusting an attestation made against an earlier read of the file.
|
|
205
|
+
return {
|
|
206
|
+
sessionId,
|
|
207
|
+
semanticSessionId: resolvedSemanticSessionId,
|
|
208
|
+
durationMs,
|
|
209
|
+
isRecordedSession,
|
|
210
|
+
rawStdinId,
|
|
211
|
+
};
|
|
162
212
|
}
|
|
163
213
|
|
|
164
214
|
/**
|
|
@@ -307,6 +357,194 @@ async function emitBackfillOutcome(kind, result, { sessionId, semanticSessionId
|
|
|
307
357
|
} catch { /* observability is best-effort */ }
|
|
308
358
|
}
|
|
309
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Emit `orchestrator.wave.final_refused` — the SIBLING event to
|
|
362
|
+
* `orchestrator.wave.completed` for every refusal path inside
|
|
363
|
+
* {@link emitFinalWaveCompleted} (#1201 Part B / Discovery D8). Deliberately
|
|
364
|
+
* a separate event name rather than `orchestrator.wave.completed` carrying
|
|
365
|
+
* `emitted:false`: existing consumers of `.completed` treat every row as a
|
|
366
|
+
* finished wave, and overloading it would silently corrupt that count.
|
|
367
|
+
*
|
|
368
|
+
* Wrapped in its OWN try/catch — independent of the caller's outer
|
|
369
|
+
* try/catch — so a telemetry failure on ONE refusal path can never surface
|
|
370
|
+
* as a failure of teardown, matching the best-effort contract every other
|
|
371
|
+
* emit in this SessionEnd hook already carries.
|
|
372
|
+
*
|
|
373
|
+
* @param {{sessionId: string|null, semanticSessionId: string|null}} ids
|
|
374
|
+
* @param {'not-recorded'|'clear'|'resume'|'unreadable'|'session-id-mismatch'|'no-wave'|'already-completed'|'exception'} reason
|
|
375
|
+
* @param {number} [waveNumber] - only when `last_wave` was resolved to a
|
|
376
|
+
* positive number before the refusal (currently only `already-completed`).
|
|
377
|
+
* @returns {Promise<void>}
|
|
378
|
+
*/
|
|
379
|
+
async function emitFinalRefused({ sessionId, semanticSessionId }, reason, waveNumber) {
|
|
380
|
+
try {
|
|
381
|
+
await emitEvent('orchestrator.wave.final_refused', {
|
|
382
|
+
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
383
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
384
|
+
reason,
|
|
385
|
+
...(typeof waveNumber === 'number' ? { wave_number: waveNumber } : {}),
|
|
386
|
+
emitted_by: 'on-session-end',
|
|
387
|
+
});
|
|
388
|
+
} catch { /* best-effort — a refusal record must never itself block teardown */ }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Emit the FINAL `orchestrator.wave.completed` of the session (#1193).
|
|
393
|
+
*
|
|
394
|
+
* `hooks/post-tool-batch-wave-signal.mjs` closes wave N-1 only at an N-1→N
|
|
395
|
+
* transition, so the LAST wave of every session never received a completion —
|
|
396
|
+
* measured fleet-wide 2026-09-02 as 296 gaps over 296 wave runs (1018 started
|
|
397
|
+
* vs 722 completed), i.e. EXACTLY one missing final completion per run. The
|
|
398
|
+
* comment that claimed the coordinator emitted it at session close described a
|
|
399
|
+
* step that never existed. SessionEnd is that emitter.
|
|
400
|
+
*
|
|
401
|
+
* Deliberately SessionEnd-only: `on-stop.mjs` is not mirrored, so the ledger
|
|
402
|
+
* keeps the closed-vs-abandoned split measurable.
|
|
403
|
+
*
|
|
404
|
+
* Idempotent via the `last_wave_completed` high-water mark, written by both
|
|
405
|
+
* emitters and preserved across clear/compact by `on-session-start.mjs`.
|
|
406
|
+
* Emits nothing when `last_wave` is absent or 0 — an Express-Path or
|
|
407
|
+
* coordinator-direct session never batched, and zero waves is the correct
|
|
408
|
+
* reading there, not a gap.
|
|
409
|
+
*
|
|
410
|
+
* OWNERSHIP-GATED (#1193 review F1). `.orchestrator/current-session.json` is a
|
|
411
|
+
* single repo-global file describing whichever session most recently ran
|
|
412
|
+
* SessionStart — routinely a DIFFERENT, still-live session when two windows
|
|
413
|
+
* share this working copy. Emitting unguarded would (a) close a PEER's live
|
|
414
|
+
* wave with a completion the peer never reached, and (b) write
|
|
415
|
+
* `last_wave_completed` into the peer's file, so the peer's own SessionEnd then
|
|
416
|
+
* stays silent — preserving the very #1193 gap this closes, on the wrong
|
|
417
|
+
* session. So this reuses the SAME `isRecordedSession` predicate
|
|
418
|
+
* `resolveSession()` applies to `durationMs` and `semanticSessionId` (#863
|
|
419
|
+
* defect (c)); when it is false, nothing is emitted and nothing is written.
|
|
420
|
+
*
|
|
421
|
+
* REASON-GATED for `/clear` AND `resume` (#1193 review F2 + W4c Q3-MED-2). The
|
|
422
|
+
* SessionEnd matcher is empty, so `/clear` fires this hook mid-wave while the
|
|
423
|
+
* LOGICAL session continues (`hooks/on-session-start.mjs:383` preserves
|
|
424
|
+
* `last_wave` / `last_wave_completed` across exactly that). A `resume` is the
|
|
425
|
+
* SAME class — start preserves the marker across a resume of the same logical
|
|
426
|
+
* session just as it does across a clear — and resume is the MORE common of the
|
|
427
|
+
* two (fleet n = 1498 `session.ended`, 2026-09-02: 12 resume vs 9 clear).
|
|
428
|
+
* Closing the live wave on either is premature, and the preserved marker would
|
|
429
|
+
* then suppress the real completion later. Fleet `session.ended` reasons, re-measured
|
|
430
|
+
* 2026-09-02 over every repo's `.orchestrator/metrics/events.jsonl` under
|
|
431
|
+
* `~/Projects` (glob written as a path segment on purpose — a literal star
|
|
432
|
+
* followed by a slash would close this comment), except
|
|
433
|
+
* `EventDrop.at-deps-2026-09` — n = 1335: 1286 other, 27 completed, 12 resume,
|
|
434
|
+
* 8 clear, 1 error, 1 close. (Same denominator and date as the
|
|
435
|
+
* `session.ended` row in `docs/audits/2026-09-02-fleet-instruments.md`; the two
|
|
436
|
+
* disagreed by ~150 before W4a F-F because each counted a different repo set.)
|
|
437
|
+
*
|
|
438
|
+
* Strictly best-effort: never throws, never blocks teardown.
|
|
439
|
+
*
|
|
440
|
+
* SIX silent refusal paths (#1201 Part B / Discovery D8 — one more than the
|
|
441
|
+
* issue's original five) previously returned with no trace anywhere: per
|
|
442
|
+
* `.claude/rules/host-resources.md` § HR-105, a refusal that writes nothing
|
|
443
|
+
* is unfalsifiable. Every refusal now emits a SIBLING event,
|
|
444
|
+
* `orchestrator.wave.final_refused`, via {@link emitFinalRefused} — never
|
|
445
|
+
* `orchestrator.wave.completed` itself with an `emitted:false` flag, because
|
|
446
|
+
* that event's consumers treat every row as a finished wave (D8's explicit
|
|
447
|
+
* recommendation). Each emit is wrapped in its OWN try/catch inside
|
|
448
|
+
* `emitFinalRefused` so telemetry can never block teardown, on top of this
|
|
449
|
+
* function's own outer catch (which now also emits `reason: 'exception'`,
|
|
450
|
+
* best-effort).
|
|
451
|
+
*
|
|
452
|
+
* @param {string} projectRoot
|
|
453
|
+
* @param {{sessionId: string|null, semanticSessionId: string|null,
|
|
454
|
+
* isRecordedSession: boolean, reason: string,
|
|
455
|
+
* rawStdinId: string|null}} ctx
|
|
456
|
+
* @returns {Promise<{emitted: true, wave_number: number}|{emitted: false, reason: string}>}
|
|
457
|
+
* The return value is informational only — every branch has already
|
|
458
|
+
* persisted its own outcome via `orchestrator.wave.completed` or
|
|
459
|
+
* `orchestrator.wave.final_refused` by the time this resolves.
|
|
460
|
+
*/
|
|
461
|
+
async function emitFinalWaveCompleted(
|
|
462
|
+
projectRoot,
|
|
463
|
+
{ sessionId, semanticSessionId, isRecordedSession, reason, rawStdinId },
|
|
464
|
+
) {
|
|
465
|
+
const ids = { sessionId, semanticSessionId };
|
|
466
|
+
try {
|
|
467
|
+
// F1 — never speak for a session current-session.json does not describe.
|
|
468
|
+
if (!isRecordedSession) {
|
|
469
|
+
await emitFinalRefused(ids, 'not-recorded');
|
|
470
|
+
return { emitted: false, reason: 'not-recorded' };
|
|
471
|
+
}
|
|
472
|
+
// F2 — `/clear` ends the HARNESS session, not the logical one. `resume` is
|
|
473
|
+
// the SAME class (W4c Q3-MED-2): `on-session-start.mjs` preserves
|
|
474
|
+
// `last_wave` / `last_wave_completed` across a resume of the same logical
|
|
475
|
+
// session exactly as it does across a clear, and resume is the MORE common
|
|
476
|
+
// of the two (fleet n=1498, 2026-09-02: 12 resume vs 9 clear).
|
|
477
|
+
if (reason === 'clear' || reason === 'resume') {
|
|
478
|
+
await emitFinalRefused(ids, reason);
|
|
479
|
+
return { emitted: false, reason };
|
|
480
|
+
}
|
|
481
|
+
const sessionFile = path.join(projectRoot, '.orchestrator', 'current-session.json');
|
|
482
|
+
let parsed = null;
|
|
483
|
+
try {
|
|
484
|
+
parsed = JSON.parse(await fs.readFile(sessionFile, 'utf8'));
|
|
485
|
+
} catch {
|
|
486
|
+
// absent or malformed → nothing attestable to close
|
|
487
|
+
await emitFinalRefused(ids, 'unreadable');
|
|
488
|
+
return { emitted: false, reason: 'unreadable' };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// W4c Q1-LOW-TOCTOU — `isRecordedSession` was attested against the FIRST
|
|
492
|
+
// read of this file (in `resolveSession()`); the values acted on below come
|
|
493
|
+
// from this SECOND read. Re-verify ownership here rather than inheriting a
|
|
494
|
+
// stale attestation. A genuine swap BETWEEN the two reads is not testable
|
|
495
|
+
// without a seam, and none is added for it — the peer-id case pins the
|
|
496
|
+
// re-check, and this predicate is what makes the window harmless.
|
|
497
|
+
if (rawStdinId === null || parsed?.session_id !== rawStdinId) {
|
|
498
|
+
await emitFinalRefused(ids, 'session-id-mismatch');
|
|
499
|
+
return { emitted: false, reason: 'session-id-mismatch' };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const lastWave = parsed?.last_wave;
|
|
503
|
+
if (typeof lastWave !== 'number' || !(lastWave > 0)) {
|
|
504
|
+
await emitFinalRefused(ids, 'no-wave');
|
|
505
|
+
return { emitted: false, reason: 'no-wave' };
|
|
506
|
+
}
|
|
507
|
+
// W4c Q3-MED-3(iii) / Q3-LOW-4 — strictly ABOVE the high-water mark, not
|
|
508
|
+
// merely different from it: a marker AHEAD of `last_wave` (written by the
|
|
509
|
+
// batch hook's explicit `wave-complete{N}` branch) means this wave is
|
|
510
|
+
// already closed, and a non-integer marker (`'3'`, `null`) counts as
|
|
511
|
+
// ABSENT rather than as "different".
|
|
512
|
+
const marker = Number.isInteger(parsed?.last_wave_completed) ? parsed.last_wave_completed : 0;
|
|
513
|
+
if (!(lastWave > marker)) {
|
|
514
|
+
await emitFinalRefused(ids, 'already-completed', lastWave);
|
|
515
|
+
return { emitted: false, reason: 'already-completed' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
await emitEvent('orchestrator.wave.completed', {
|
|
519
|
+
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
520
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
521
|
+
wave_number: lastWave,
|
|
522
|
+
reason: 'session-end',
|
|
523
|
+
emitted_by: 'on-session-end',
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Monotone, exactly as the batch hook's `maxWave()` — the mark may only
|
|
527
|
+
// ever rise, whichever of the three writers gets here last (W4c Q3-MED-3).
|
|
528
|
+
const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
529
|
+
...current,
|
|
530
|
+
last_wave_completed: Number.isInteger(current?.last_wave_completed)
|
|
531
|
+
&& current.last_wave_completed > lastWave
|
|
532
|
+
? current.last_wave_completed
|
|
533
|
+
: lastWave,
|
|
534
|
+
}), 'ose');
|
|
535
|
+
// The wave.completed EVENT above already fired regardless — this only
|
|
536
|
+
// withholds the shared-file high-water mark on a non-ENOENT failure.
|
|
537
|
+
if (!markResult.ok) {
|
|
538
|
+
console.error(`on-session-end: last_wave_completed mark skipped (${markResult.reason})`);
|
|
539
|
+
}
|
|
540
|
+
return { emitted: true, wave_number: lastWave };
|
|
541
|
+
} catch {
|
|
542
|
+
// best-effort — a SessionEnd hook must never block teardown
|
|
543
|
+
await emitFinalRefused(ids, 'exception');
|
|
544
|
+
return { emitted: false, reason: 'exception' };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
310
548
|
// ---------------------------------------------------------------------------
|
|
311
549
|
// main
|
|
312
550
|
// ---------------------------------------------------------------------------
|
|
@@ -317,7 +555,8 @@ async function main() {
|
|
|
317
555
|
|
|
318
556
|
const reason =
|
|
319
557
|
typeof input?.reason === 'string' && input.reason.length > 0 ? input.reason : 'other';
|
|
320
|
-
const { sessionId, semanticSessionId, durationMs } =
|
|
558
|
+
const { sessionId, semanticSessionId, durationMs, isRecordedSession, rawStdinId } =
|
|
559
|
+
await resolveSession(input, projectRoot);
|
|
321
560
|
|
|
322
561
|
// Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
|
|
323
562
|
// JSONL record AND fires the optional Clank webhook with the SAME event name.
|
|
@@ -332,13 +571,40 @@ async function main() {
|
|
|
332
571
|
// attestation bar is the #863 defect (c) guard inside `resolveSession()`: an
|
|
333
572
|
// ending session that is NOT the one current-session.json describes resolves
|
|
334
573
|
// `semanticSessionId: null` and therefore emits no key here.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
574
|
+
// #1193 — close the last wave BEFORE the terminal session event, so the
|
|
575
|
+
// ledger's wave lifecycle is balanced within the session's own window. Gated
|
|
576
|
+
// on the SAME `isRecordedSession` attestation as the identity keys above, and
|
|
577
|
+
// skipped for `reason === 'clear'` and `reason === 'resume'` alike — both end
|
|
578
|
+
// the HARNESS session while the LOGICAL one continues; see the emitter's docblock.
|
|
579
|
+
//
|
|
580
|
+
// #1201 Part B — the return is `{emitted:true, wave_number}` or
|
|
581
|
+
// `{emitted:false, reason}`, but it is intentionally NOT branched on here:
|
|
582
|
+
// both outcomes already persisted themselves (`orchestrator.wave.completed`
|
|
583
|
+
// or the sibling `orchestrator.wave.final_refused`) before this call
|
|
584
|
+
// resolves, so nothing downstream in this hook needs to react to it.
|
|
585
|
+
await emitFinalWaveCompleted(projectRoot, {
|
|
586
|
+
sessionId,
|
|
587
|
+
semanticSessionId,
|
|
588
|
+
isRecordedSession,
|
|
338
589
|
reason,
|
|
339
|
-
|
|
590
|
+
rawStdinId,
|
|
340
591
|
});
|
|
341
592
|
|
|
593
|
+
// #1183 — a malformed record throws EventValidationError BEFORE any side
|
|
594
|
+
// effect (scripts/lib/events.mjs); this hook must never abort on that, so
|
|
595
|
+
// the emit is wrapped rather than left to propagate.
|
|
596
|
+
try {
|
|
597
|
+
await emitEvent('orchestrator.session.ended', {
|
|
598
|
+
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
599
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
600
|
+
reason,
|
|
601
|
+
// Omit-never-fabricate: only a MEASURED span is written. `null` here means
|
|
602
|
+
// the ending session is not the recorded one (or no start time parsed) —
|
|
603
|
+
// absence must stay absence, not become a zero-length session.
|
|
604
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { duration_ms: durationMs } : {}),
|
|
605
|
+
});
|
|
606
|
+
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
607
|
+
|
|
342
608
|
// -------------------------------------------------------------------------
|
|
343
609
|
// C1 (#724) — Close-through backfill + deterministic lock release.
|
|
344
610
|
// Both are STRICTLY best-effort: a SessionEnd hook must never block teardown
|
|
@@ -124,6 +124,30 @@ function shortSessionId(id) {
|
|
|
124
124
|
return id.length > 24 ? id.slice(0, 8) : id;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* GH#67 — render the provenance marker for ONE mechanically-detected peer.
|
|
129
|
+
*
|
|
130
|
+
* `discoverActiveSessions()` annotates registry-sourced peers additively
|
|
131
|
+
* (`registryOnly` / `lockSuperseded` / `lockOwnerId`); lock-sourced peers carry
|
|
132
|
+
* none of the three, so they render byte-identically to the pre-GH#67 banner.
|
|
133
|
+
*
|
|
134
|
+
* `lockSuperseded: true` means a LIVE lock at THIS root is held by a different
|
|
135
|
+
* raw session_id than the registry entry — the common cause is a finished task
|
|
136
|
+
* whose SessionEnd never ran, leaving a fresh-but-orphaned registry record.
|
|
137
|
+
* It is a HINT, never a verdict: the session lock is advisory, so the entry may
|
|
138
|
+
* equally be a live peer that lost the acquire race (#1085). The marker is
|
|
139
|
+
* therefore purely additive — it never removes the peer, and never changes the
|
|
140
|
+
* count or the WARN decision (HR-106).
|
|
141
|
+
*
|
|
142
|
+
* @param {{registryOnly?:boolean, lockSuperseded?:boolean, lockOwnerId?:string|null}} peer
|
|
143
|
+
* @returns {string} '' for a lock-sourced peer, else a bracketed marker.
|
|
144
|
+
*/
|
|
145
|
+
function supersessionMarker(peer) {
|
|
146
|
+
if (!peer || peer.registryOnly !== true) return '';
|
|
147
|
+
if (peer.lockSuperseded === true) return ' [registry-only, superseded]';
|
|
148
|
+
return ' [registry-only]';
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
/**
|
|
128
152
|
* Queue the ONE `additionalContext` string for the single end-of-hook flush.
|
|
129
153
|
* Last writer wins; there is deliberately no accumulation, because every
|
|
@@ -380,6 +404,12 @@ async function resolveSessionId(input, projectRoot) {
|
|
|
380
404
|
if (Object.prototype.hasOwnProperty.call(prev, 'last_batch')) {
|
|
381
405
|
preserved.last_batch = prev.last_batch;
|
|
382
406
|
}
|
|
407
|
+
// #1193 — the final-wave completion marker must survive a
|
|
408
|
+
// clear/compact too: dropping it re-arms a duplicate SessionEnd
|
|
409
|
+
// `orchestrator.wave.completed` for a wave already closed.
|
|
410
|
+
if (Object.prototype.hasOwnProperty.call(prev, 'last_wave_completed')) {
|
|
411
|
+
preserved.last_wave_completed = prev.last_wave_completed;
|
|
412
|
+
}
|
|
383
413
|
}
|
|
384
414
|
} catch { /* absent / unparseable → no preservation (reset) */ }
|
|
385
415
|
}
|
|
@@ -738,6 +768,11 @@ async function main() {
|
|
|
738
768
|
} catch { /* hook must remain non-blocking */ }
|
|
739
769
|
|
|
740
770
|
let peers = [];
|
|
771
|
+
// GH#67 consumer half — how many mechanically-detected peers were registry-
|
|
772
|
+
// sourced entries that a LIVE lock at this root supersedes. Counted for the
|
|
773
|
+
// session.started payload below; NEVER subtracted from any peer count
|
|
774
|
+
// (HR-106: the banner reports the number the verdict judged).
|
|
775
|
+
let mechanicalPeersSuperseded = 0;
|
|
741
776
|
try {
|
|
742
777
|
await sweepZombies().catch(() => ({ removed: [], logged: 0 }));
|
|
743
778
|
try {
|
|
@@ -839,6 +874,7 @@ async function main() {
|
|
|
839
874
|
const { discoverActiveSessions } = await import('../scripts/lib/session-discovery.mjs');
|
|
840
875
|
const allActive = await discoverActiveSessions(projectRoot);
|
|
841
876
|
const mechanicalPeers = allActive.filter((s) => s.sessionId !== sessionId);
|
|
877
|
+
mechanicalPeersSuperseded = mechanicalPeers.filter((p) => p.lockSuperseded === true).length;
|
|
842
878
|
if (mechanicalPeers.length > 0) {
|
|
843
879
|
// #1137 part 1 — say WHERE, not just how many. discoverActiveSessions()
|
|
844
880
|
// walks EVERY path `git worktree list` reports, which includes worktrees
|
|
@@ -862,11 +898,14 @@ async function main() {
|
|
|
862
898
|
// a lock written without the field yields undefined here, while
|
|
863
899
|
// sessionFromRegistryEntry() always defaults it to 'session'.
|
|
864
900
|
const mode = typeof p.mode === 'string' && p.mode.length > 0 ? `:${p.mode}` : '';
|
|
865
|
-
return `${where}:${shortSessionId(p.sessionId)}${mode}`;
|
|
901
|
+
return `${where}:${shortSessionId(p.sessionId)}${mode}${supersessionMarker(p)}`;
|
|
866
902
|
})
|
|
867
903
|
.join(', ');
|
|
868
904
|
const overflow = mechanicalPeers.length > 3 ? ` +${mechanicalPeers.length - 3} more` : '';
|
|
869
905
|
pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in this repo's worktree set (${summary}${overflow})`);
|
|
906
|
+
if (mechanicalPeersSuperseded > 0) {
|
|
907
|
+
pushBanner(` ${mechanicalPeersSuperseded} of them registry-only and superseded by this root's live lock — likely a finished task without SessionEnd (GH#67). Still counted above: the session lock is advisory.`);
|
|
908
|
+
}
|
|
870
909
|
}
|
|
871
910
|
} catch { /* best effort — banner is informational, never blocks */ }
|
|
872
911
|
}
|
|
@@ -914,6 +953,10 @@ async function main() {
|
|
|
914
953
|
branch,
|
|
915
954
|
session_id: sessionId,
|
|
916
955
|
peer_count: peers.length,
|
|
956
|
+
// GH#67 — additive: how many mechanically-detected peers a live lock at
|
|
957
|
+
// this root supersedes. `peer_count` is deliberately unchanged; this field
|
|
958
|
+
// makes the supersession rate measurable instead of inferred (HR-105).
|
|
959
|
+
peers_superseded: mechanicalPeersSuperseded,
|
|
917
960
|
};
|
|
918
961
|
if (bannerData) {
|
|
919
962
|
payload.host_class = bannerData.host.host_class;
|
|
@@ -935,7 +978,12 @@ async function main() {
|
|
|
935
978
|
payload.memory_pressure_pct_free = bannerData.resources.memory_pressure_pct_free ?? null;
|
|
936
979
|
payload.peer_sessions_count = bannerData.resources.peer_sessions_count ?? null;
|
|
937
980
|
}
|
|
938
|
-
|
|
981
|
+
// #1183 — a malformed record throws EventValidationError BEFORE any side
|
|
982
|
+
// effect (scripts/lib/events.mjs); this hook must never abort on that, so
|
|
983
|
+
// the emit is wrapped rather than left to propagate.
|
|
984
|
+
try {
|
|
985
|
+
await emitEvent('orchestrator.session.started', payload);
|
|
986
|
+
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
939
987
|
|
|
940
988
|
// #1138 — one-time telemetry-consent nudge. Queued (never written) here so it
|
|
941
989
|
// rides the single stdout envelope below.
|