session-orchestrator 3.21.0 → 3.22.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/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +101 -0
- package/README.md +10 -10
- package/agents/memory-proposal-collector.md +6 -4
- package/commands/eli5.md +33 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/scope-collision-guard.md +3 -3
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +24 -92
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +7 -8
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/ci-status-banner.mjs +63 -57
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +147 -50
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +6 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +32 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/discovery/SKILL.md +28 -24
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/session-end/SKILL.md +53 -20
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-start/SKILL.md +69 -35
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +27 -5
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
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.22.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -105,6 +105,16 @@
|
|
|
105
105
|
"timeout": 5
|
|
106
106
|
}
|
|
107
107
|
]
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"matcher": "AskUserQuestion",
|
|
111
|
+
"hooks": [
|
|
112
|
+
{
|
|
113
|
+
"type": "command",
|
|
114
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-auq-clarity.mjs\"",
|
|
115
|
+
"timeout": 5
|
|
116
|
+
}
|
|
117
|
+
]
|
|
108
118
|
}
|
|
109
119
|
],
|
|
110
120
|
"PostToolUse": [
|
package/hooks/on-session-end.mjs
CHANGED
|
@@ -34,9 +34,7 @@ import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.
|
|
|
34
34
|
import {
|
|
35
35
|
readLockDetailed,
|
|
36
36
|
release,
|
|
37
|
-
isLockLive,
|
|
38
37
|
loadOwnerProof,
|
|
39
|
-
isLockOwnedByProof,
|
|
40
38
|
OWNER_PROOF_RELPATH,
|
|
41
39
|
} from '../scripts/lib/session-lock.mjs';
|
|
42
40
|
import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
@@ -83,20 +81,16 @@ async function readStdinJson() {
|
|
|
83
81
|
* `semanticSessionId` is read from current-session.json (present since #587):
|
|
84
82
|
* it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
|
|
85
83
|
* keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
|
|
86
|
-
* a harness UUID with no lock.acquired bridge.
|
|
84
|
+
* a harness UUID with no lock.acquired bridge. It is never a lock-release
|
|
85
|
+
* ownership identity; only the raw/native `sessionId` may release a live lock.
|
|
87
86
|
*
|
|
88
87
|
* #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
|
|
89
88
|
* IS the recorded one" check as `durationMs` above. `current-session.json` is
|
|
90
89
|
* a single repo-global file: it always reflects whichever session most
|
|
91
90
|
* recently ran SessionStart, which may be a DIFFERENT, still-live session
|
|
92
|
-
* when multiple windows share this repo.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
|
|
96
|
-
* check then matched that OTHER, still-running session's lock and released
|
|
97
|
-
* it. Gating this field closes that window: an unrelated/mismatched ending
|
|
98
|
-
* session now resolves `semanticSessionId: null`, so `ownBySemantic` can
|
|
99
|
-
* never accidentally fire on someone else's identity.
|
|
91
|
+
* when multiple windows share this repo. An unrelated/mismatched ending
|
|
92
|
+
* session therefore resolves `semanticSessionId: null` rather than inheriting
|
|
93
|
+
* another live session's backfill identity.
|
|
100
94
|
*
|
|
101
95
|
* @param {object|null} input
|
|
102
96
|
* @param {string} projectRoot
|
|
@@ -181,24 +175,15 @@ async function main() {
|
|
|
181
175
|
await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
|
|
182
176
|
} catch { /* best-effort — never block teardown */ }
|
|
183
177
|
|
|
184
|
-
// (b) Deterministic lock release — ONLY
|
|
185
|
-
//
|
|
186
|
-
//
|
|
178
|
+
// (b) Deterministic lock release — ONLY a lock whose raw/native session_id
|
|
179
|
+
// exactly matches this ending session's raw/native sessionId. Semantic
|
|
180
|
+
// IDs remain lifecycle/backfill metadata and persisted proofs remain an
|
|
181
|
+
// additional delete defense, but neither can heal a raw-ID mismatch.
|
|
187
182
|
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// (2) when the harness UUID rotates (clear/compact/resume) and
|
|
193
|
-
// current-session.json's semantic_session_id is null/stale, BOTH
|
|
194
|
-
// ownership checks are false and this branch never runs release()
|
|
195
|
-
// at all — the lock survives until the NEXT session-start's own
|
|
196
|
-
// reaper sweep discovers it.
|
|
197
|
-
// Both are addressed below without loosening the strict
|
|
198
|
-
// never-release-a-foreign-lock invariant: release() only ever runs when
|
|
199
|
-
// ownership matched; the reconciliation fallback only ever runs through
|
|
200
|
-
// reapRepoLock(), which independently NEVER reaps a live lease (and only
|
|
201
|
-
// ever reaps on this same host with a dead recorded PID).
|
|
183
|
+
// A mismatch routes only through reconciliation. The reaper independently
|
|
184
|
+
// never touches a live lease, a cross-host lease, or a lease whose
|
|
185
|
+
// recorded PID is still alive on this host; this retains dead/stale-lock
|
|
186
|
+
// recovery without granting a SessionEnd hook foreign-lock ownership.
|
|
202
187
|
try {
|
|
203
188
|
// readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
|
|
204
189
|
// readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
|
|
@@ -219,61 +204,15 @@ async function main() {
|
|
|
219
204
|
} catch { /* observability is best-effort */ }
|
|
220
205
|
} else if (lockDetail.status === 'ok') {
|
|
221
206
|
const lock = lockDetail.lock;
|
|
222
|
-
const
|
|
223
|
-
const ownBySemanticStrict =
|
|
224
|
-
semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
|
|
225
|
-
// #863 (d) — lock-shape trap: some on-disk locks store the semantic id
|
|
226
|
-
// directly in `session_id` with no separate `semantic_session_id` field
|
|
227
|
-
// at all (the "generated-semantic" acquisition path in
|
|
228
|
-
// on-session-start.mjs mints `session_id === the semantic id`, and
|
|
229
|
-
// bootstrapLock's v2 enrichment step never ran for that lock). Without
|
|
230
|
-
// this fallback, ownBySemanticStrict is dead code for that shape.
|
|
231
|
-
const ownBySemanticFallback =
|
|
232
|
-
semanticSessionId !== null && lock.session_id === semanticSessionId;
|
|
233
|
-
const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
|
|
207
|
+
const ownByRawId = sessionId !== null && lock.session_id === sessionId;
|
|
234
208
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const proof = loadOwnerProof({ repoRoot: projectRoot });
|
|
242
|
-
const ownByProof = isLockOwnedByProof(lock, proof);
|
|
209
|
+
if (ownByRawId) {
|
|
210
|
+
// The proof is deliberately passed to release() only after this raw-ID
|
|
211
|
+
// check. release() validates a supplied proof against its fresh on-disk
|
|
212
|
+
// read, preserving it as a second defense for the raw-owned path without
|
|
213
|
+
// allowing proof equality to establish ownership by itself.
|
|
214
|
+
const proof = loadOwnerProof({ repoRoot: projectRoot });
|
|
243
215
|
|
|
244
|
-
// #906-class fix: the semantic session id
|
|
245
|
-
// (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
|
|
246
|
-
// id-counter can hand out the SAME id to two different session
|
|
247
|
-
// processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
|
|
248
|
-
// and `main-2026-07-29-session-1`, each assigned to two distinct
|
|
249
|
-
// sessions). A live lock matched ONLY via a collidable semantic id
|
|
250
|
-
// (strict OR fallback, never corroborated by a non-collidable factor)
|
|
251
|
-
// is therefore NEVER trusted for release.
|
|
252
|
-
//
|
|
253
|
-
// #987 Part 2 — the discrimination the #906-class fix escalated as
|
|
254
|
-
// "structurally unobservable" is now OBSERVABLE via the persisted
|
|
255
|
-
// owner proof: a genuine same-session UUID rotation (clear/compact)
|
|
256
|
-
// and a foreign same-day semantic collision used to produce the
|
|
257
|
-
// IDENTICAL shape here (UUID mismatch + semantic match on a live
|
|
258
|
-
// lock), so the previous fix conservatively left BOTH un-released
|
|
259
|
-
// (bounded by the ttl_hours reaper). The proof written at lock genesis
|
|
260
|
-
// (pid + host + started_at — presence-at-genesis, never the collidable
|
|
261
|
-
// session_id/semantic id, see the FACTOR CHOICE docblock on
|
|
262
|
-
// isLockOwnedByProof()) is exactly the discriminator that RCR-007
|
|
263
|
-
// escalation named: on a self-rotation the on-disk lock is FROZEN at
|
|
264
|
-
// its pre-rotation values (bootstrapLock's shouldForce is false for a
|
|
265
|
-
// rotated UUID, so it bails without touching the lock), which are the
|
|
266
|
-
// SAME values the proof captured at genesis → ownByProof is true → the
|
|
267
|
-
// lock is released correctly. A foreign same-day collision wrote its
|
|
268
|
-
// lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
|
|
269
|
-
// is false → semanticOnlyLive stays true → no release, the foreign
|
|
270
|
-
// lease survives (the never-release-a-foreign-lock invariant is
|
|
271
|
-
// untouched). No proof on disk (pre-#987 sessions, failed proof
|
|
272
|
-
// write) → ownByProof false → the conservative pre-#987 behaviour.
|
|
273
|
-
const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
|
|
274
|
-
const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
|
|
275
|
-
|
|
276
|
-
if (releaseEligible) {
|
|
277
216
|
// Defense-in-depth (#987): when a persisted proof exists, hand it to
|
|
278
217
|
// release() so the delete is double-gated (session_id match AND
|
|
279
218
|
// proof match) at the fs layer too. `proof` is `null` whenever
|
|
@@ -284,7 +223,7 @@ async function main() {
|
|
|
284
223
|
// A 'proof-mismatch' result flows into the existing release_failed
|
|
285
224
|
// breadcrumb below (releaseResult.reason surfaces verbatim).
|
|
286
225
|
const releaseResult = release({
|
|
287
|
-
sessionId
|
|
226
|
+
sessionId,
|
|
288
227
|
repoRoot: projectRoot,
|
|
289
228
|
proof,
|
|
290
229
|
});
|
|
@@ -355,16 +294,9 @@ async function main() {
|
|
|
355
294
|
} catch { /* best-effort — a leftover proof is self-invalidating */ }
|
|
356
295
|
}
|
|
357
296
|
} else {
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// is still live (semanticOnlyLive). attemptLockReconciliation() is
|
|
362
|
-
// the extracted, DI-testable seam (Issue #748) — it internally
|
|
363
|
-
// no-ops when the lease is still live (isLockLive), which is exactly
|
|
364
|
-
// what makes it safe to route the semanticOnlyLive case here too: it
|
|
365
|
-
// is otherwise best-effort, and reapRepoLock() never touches a live
|
|
366
|
-
// lease, a cross-host lease, or a lease whose recorded PID is still
|
|
367
|
-
// alive on this host.
|
|
297
|
+
// A raw-ID mismatch never grants release ownership. Reconciliation is
|
|
298
|
+
// the only remaining cleanup path; it no-ops for a live lock and safely
|
|
299
|
+
// reaps only eligible dead/stale locks.
|
|
368
300
|
await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
|
|
369
301
|
}
|
|
370
302
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
32
|
+
import { writeSync } from 'node:fs';
|
|
32
33
|
import { execFile } from 'node:child_process';
|
|
33
34
|
import { promisify } from 'node:util';
|
|
34
35
|
import { randomUUID } from 'node:crypto';
|
|
@@ -47,11 +48,67 @@ import {
|
|
|
47
48
|
detectPeers,
|
|
48
49
|
sweepZombies,
|
|
49
50
|
logSweepEvent,
|
|
51
|
+
repoPathHash,
|
|
50
52
|
} from '../scripts/lib/session-registry.mjs';
|
|
51
53
|
import { detectColdStart, consumeMarker } from '../scripts/lib/cold-start-detector.mjs';
|
|
54
|
+
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
52
55
|
|
|
53
56
|
const execFileAsync = promisify(execFile);
|
|
54
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Banner buffer (#1089 / #1052 A3a)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
//
|
|
62
|
+
// MEASURED 2026-08-21: this hook emitted FIVE independent
|
|
63
|
+
// `console.log(JSON.stringify({ systemMessage }))` calls, and Claude Code
|
|
64
|
+
// surfaces only the FIRST JSON object a SessionStart hook writes to stdout.
|
|
65
|
+
// A live run against a copied 5-peer registry produced four stdout lines of
|
|
66
|
+
// which the operator saw exactly one — the host/resource line. The three that
|
|
67
|
+
// were silently discarded included:
|
|
68
|
+
//
|
|
69
|
+
// ⚠️ 👥 Peers: 5 active (… session-orchestrator:main:wave-0 …)
|
|
70
|
+
// 🔍 Mechanical peer-detection: 1 active in same repo (…)
|
|
71
|
+
//
|
|
72
|
+
// i.e. the two lines whose entire job is to warn that ANOTHER SESSION HOLDS
|
|
73
|
+
// THIS WORKING COPY. The information was computed correctly, formatted
|
|
74
|
+
// correctly, and thrown away by the transport — the "built but not wired"
|
|
75
|
+
// class, with the wiring defect one layer below where anyone was looking.
|
|
76
|
+
//
|
|
77
|
+
// The fix is structural: every banner line goes into one buffer and leaves as
|
|
78
|
+
// ONE systemMessage. Adding a sixth banner in future therefore cannot
|
|
79
|
+
// re-introduce the bug.
|
|
80
|
+
const bannerLines = [];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Queue one or more banner lines for the single end-of-hook flush.
|
|
84
|
+
* @param {string|null|undefined} line — multi-line strings are pushed verbatim
|
|
85
|
+
*/
|
|
86
|
+
function pushBanner(line) {
|
|
87
|
+
if (typeof line !== 'string' || line.length === 0) return;
|
|
88
|
+
bannerLines.push(line);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Emit every queued banner line as ONE systemMessage envelope.
|
|
93
|
+
*
|
|
94
|
+
* Uses `fs.writeSync(1, …)` rather than `console.log`: the top-level guard
|
|
95
|
+
* calls `process.exit(0)`, which discards anything still sitting in libuv's
|
|
96
|
+
* async write queue when stdout is a pipe (see
|
|
97
|
+
* `.claude/rules/anti-pattern-console-log-process-exit-drops-stdout…`). The
|
|
98
|
+
* banner is far below the 64 KiB pipe buffer today, but a synchronous write
|
|
99
|
+
* costs nothing and removes the failure mode rather than staying under it.
|
|
100
|
+
*
|
|
101
|
+
* Idempotent: a second call after a flush is a no-op.
|
|
102
|
+
*/
|
|
103
|
+
function flushBanner() {
|
|
104
|
+
if (bannerLines.length === 0) return;
|
|
105
|
+
const payload = JSON.stringify({ systemMessage: bannerLines.join('\n') });
|
|
106
|
+
bannerLines.length = 0;
|
|
107
|
+
try {
|
|
108
|
+
writeSync(1, `${payload}\n`);
|
|
109
|
+
} catch { /* stdout closed — the hook is informational and never blocks */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
55
112
|
// ---------------------------------------------------------------------------
|
|
56
113
|
// Helpers
|
|
57
114
|
// ---------------------------------------------------------------------------
|
|
@@ -191,92 +248,43 @@ async function readStdinJson(timeoutMs = 500) {
|
|
|
191
248
|
*/
|
|
192
249
|
async function resolveSessionId(input, projectRoot) {
|
|
193
250
|
const fromStdin = (input && (input.session_id || input.sessionId)) ?? null;
|
|
251
|
+
const parsedStdinId = parseSessionId(fromStdin);
|
|
252
|
+
const rawStdinSessionId = parsedStdinId?.format === 'uuid' ? fromStdin : null;
|
|
194
253
|
|
|
195
|
-
// Mode normalization is shared by both branches
|
|
196
|
-
//
|
|
197
|
-
//
|
|
254
|
+
// Mode normalization is shared by both branches so semantic attribution is
|
|
255
|
+
// derived independently of the raw session identity. A semantic id is always
|
|
256
|
+
// descriptive metadata, never the physical lock/registry session_id.
|
|
198
257
|
const rawMode = (input && (input.mode || input.session_type)) || 'session';
|
|
199
258
|
const normalizedMode =
|
|
200
259
|
String(rawMode).toLowerCase().replace(/[^a-z-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session';
|
|
201
260
|
|
|
202
261
|
let sessionId;
|
|
203
262
|
let source;
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// when Claude Code's stdin payload provided a UUID-v4. Defaults to null
|
|
207
|
-
// and is populated below; the lock-bootstrap helper falls back to mirroring
|
|
208
|
-
// sessionId when this stays null.
|
|
263
|
+
// The lock and downstream consumers surface this independently-derived
|
|
264
|
+
// semantic attribution alongside the physical raw session id.
|
|
209
265
|
let semanticSessionId = null;
|
|
210
266
|
|
|
211
|
-
if (
|
|
212
|
-
sessionId =
|
|
267
|
+
if (rawStdinSessionId !== null) {
|
|
268
|
+
sessionId = rawStdinSessionId;
|
|
213
269
|
source = 'stdin';
|
|
214
|
-
|
|
215
|
-
// D4 #587: even on the stdin-UUID path, ALSO compute a semantic
|
|
216
|
-
// session-id so the lock and registry can surface it downstream.
|
|
217
|
-
// Best-effort: any failure leaves semanticSessionId = null which is
|
|
218
|
-
// handled gracefully by the lock-bootstrap helper.
|
|
219
|
-
try {
|
|
220
|
-
const semCandidate = await deriveSemanticCandidate({
|
|
221
|
-
projectRoot,
|
|
222
|
-
mode: normalizedMode,
|
|
223
|
-
});
|
|
224
|
-
if (semCandidate) semanticSessionId = semCandidate;
|
|
225
|
-
} catch { /* best effort — leave semanticSessionId = null */ }
|
|
226
270
|
} else {
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
mode: normalizedMode,
|
|
233
|
-
});
|
|
234
|
-
if (!candidate) throw new Error('semantic-derivation-empty');
|
|
235
|
-
|
|
236
|
-
// Race-free host-wide uniqueness: try to atomically claim the registry slot
|
|
237
|
-
// for this sessionId via O_CREAT|O_EXCL. If another process won the race
|
|
238
|
-
// (EEXIST), fall back to UUID-v4 to guarantee distinct sessionIds across
|
|
239
|
-
// parallel hook invocations. The empty file is immediately overwritten by
|
|
240
|
-
// registerSelf() with the real entry — _validEntry() in readers filters
|
|
241
|
-
// any zero-content artifact during the millisecond gap.
|
|
242
|
-
try {
|
|
243
|
-
// entryPath() is NOT exported from session-registry.mjs (it is an
|
|
244
|
-
// internal helper); replicate its body inline so the race-free slot
|
|
245
|
-
// claim actually succeeds. Without this inline build, the
|
|
246
|
-
// destructuring on `entryPath` returns undefined and calling it
|
|
247
|
-
// throws TypeError → silent UUID-fallback, defeating the semantic-id
|
|
248
|
-
// generation path entirely. Pre-existing bug pre-dating Epic #583 P3.
|
|
249
|
-
const { activeDir } = await import('../scripts/lib/session-registry.mjs');
|
|
250
|
-
const fsAsync = await import('node:fs/promises');
|
|
251
|
-
await fsAsync.mkdir(activeDir(), { recursive: true });
|
|
252
|
-
const claimPath = path.join(activeDir(), `${candidate}.json`);
|
|
253
|
-
const handle = await fsAsync.open(claimPath, 'wx'); // O_CREAT|O_EXCL — fails with EEXIST on collision
|
|
254
|
-
await handle.close();
|
|
255
|
-
sessionId = candidate;
|
|
256
|
-
semanticSessionId = candidate;
|
|
257
|
-
source = 'generated-semantic';
|
|
258
|
-
} catch (claimErr) {
|
|
259
|
-
if (claimErr.code === 'EEXIST') {
|
|
260
|
-
// Collision with another parallel hook — fall back to UUID-v4 silently.
|
|
261
|
-
// Hook is informational-only; never write to stderr per existing convention.
|
|
262
|
-
sessionId = randomUUID();
|
|
263
|
-
source = 'generated-uuid-fallback-collision';
|
|
264
|
-
// We still surface the semantic candidate as semanticSessionId so the
|
|
265
|
-
// lock reflects what this session would have been called — useful for
|
|
266
|
-
// forensics even when the UUID won the registry-slot race.
|
|
267
|
-
semanticSessionId = candidate;
|
|
268
|
-
} else {
|
|
269
|
-
throw claimErr; // surfaces to outer catch → UUID fallback
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
} catch {
|
|
273
|
-
// Semantic id generation failed for any reason — silently fall back to UUID-v4.
|
|
274
|
-
// Hook is informational-only; never write to stderr per existing convention.
|
|
275
|
-
sessionId = randomUUID();
|
|
276
|
-
source = 'generated-uuid-fallback';
|
|
277
|
-
}
|
|
271
|
+
// A missing, malformed, or semantic stdin value is not a trustworthy raw
|
|
272
|
+
// identity. Generate the physical id locally; semantic derivation below is
|
|
273
|
+
// attribution only and must never become the lock/registry key.
|
|
274
|
+
sessionId = randomUUID();
|
|
275
|
+
source = 'generated-uuid';
|
|
278
276
|
}
|
|
279
277
|
|
|
278
|
+
// Derive a descriptive semantic label for either raw-id source. Best-effort:
|
|
279
|
+
// a failure leaves it null without changing the physical raw session_id.
|
|
280
|
+
try {
|
|
281
|
+
const semCandidate = await deriveSemanticCandidate({
|
|
282
|
+
projectRoot,
|
|
283
|
+
mode: normalizedMode,
|
|
284
|
+
});
|
|
285
|
+
if (semCandidate) semanticSessionId = semCandidate;
|
|
286
|
+
} catch { /* best effort — leave semanticSessionId = null */ }
|
|
287
|
+
|
|
280
288
|
try {
|
|
281
289
|
const dir = path.join(projectRoot, '.orchestrator');
|
|
282
290
|
await mkdir(dir, { recursive: true });
|
|
@@ -415,15 +423,43 @@ async function emitHostBanner(projectRoot) {
|
|
|
415
423
|
if (!host || !resources) return null;
|
|
416
424
|
|
|
417
425
|
const hostLine = `🖥️ Host: ${host.host_class} · ${host.ram_total_gb} GB RAM · ${host.platform ?? 'unknown'} · ${host.is_ssh ? 'ssh' : 'local'}`;
|
|
418
|
-
|
|
426
|
+
|
|
427
|
+
// #1089: report the memory number a human can ACT on.
|
|
428
|
+
//
|
|
429
|
+
// The old line printed `ram_free_gb`, which on Darwin is `os.freemem()` =
|
|
430
|
+
// `Pages free` only. Median across 1477 measured session starts: 0.4 GB —
|
|
431
|
+
// on hosts with 24-128 GB installed. So this banner spent four months
|
|
432
|
+
// telling the operator the machine was seconds from death while the same
|
|
433
|
+
// machine reported 40%+ memory free and ran full waves clean. Six repos
|
|
434
|
+
// logged that false alarm as a learning; one capped agents for five
|
|
435
|
+
// consecutive sessions off this number.
|
|
436
|
+
//
|
|
437
|
+
// Precedence mirrors evaluate()'s memorySignal(): pressure > available >
|
|
438
|
+
// free. `free` survives only as the last resort — on Linux/Windows it is
|
|
439
|
+
// genuinely accurate, which is exactly where nothing better is published.
|
|
440
|
+
const memLine = (() => {
|
|
441
|
+
if (resources.memory_pressure_pct_free !== null && resources.memory_pressure_pct_free !== undefined) {
|
|
442
|
+
return `${resources.memory_pressure_pct_free}% memory free (OS pressure)`;
|
|
443
|
+
}
|
|
444
|
+
if (resources.ram_available_gb !== null && resources.ram_available_gb !== undefined) {
|
|
445
|
+
return `${resources.ram_available_gb.toFixed(1)} GB available`;
|
|
446
|
+
}
|
|
447
|
+
return `${resources.ram_free_gb.toFixed(1)} GB free`;
|
|
448
|
+
})();
|
|
449
|
+
|
|
450
|
+
// Peer SESSIONS, not Claude PROCESSES. Measured ratio 6.0:1 — the old
|
|
451
|
+
// suffix read "17 Claude processes running" on a host carrying 3 sessions,
|
|
452
|
+
// which is alarming and means nothing actionable.
|
|
453
|
+
const peerSuffix = resources.peer_sessions_count === null || resources.peer_sessions_count === undefined
|
|
419
454
|
? ''
|
|
420
|
-
: ` · ${resources.
|
|
421
|
-
const resourceLine = `📊 Resources: ${
|
|
422
|
-
const banner = `${hostLine}\n${resourceLine}`;
|
|
455
|
+
: ` · ${resources.peer_sessions_count} peer session${resources.peer_sessions_count === 1 ? '' : 's'}`;
|
|
456
|
+
const resourceLine = `📊 Resources: ${memLine} · CPU ${resources.cpu_load_pct}%${peerSuffix}`;
|
|
423
457
|
|
|
424
|
-
// systemMessage envelope is the Claude Code hook contract;
|
|
425
|
-
//
|
|
426
|
-
|
|
458
|
+
// systemMessage envelope is the Claude Code hook contract; buffered so all
|
|
459
|
+
// banner lines leave as ONE object (see flushBanner — Claude Code reads
|
|
460
|
+
// only the first).
|
|
461
|
+
pushBanner(hostLine);
|
|
462
|
+
pushBanner(resourceLine);
|
|
427
463
|
|
|
428
464
|
return { host, resources };
|
|
429
465
|
} catch {
|
|
@@ -464,11 +500,7 @@ async function main() {
|
|
|
464
500
|
// Config / STATE.md / git, and only then the tool. The banner carries the
|
|
465
501
|
// ORDER, not an absolute; full routing + exceptions in
|
|
466
502
|
// .claude/rules/ask-via-tool.md.
|
|
467
|
-
|
|
468
|
-
console.log(JSON.stringify({
|
|
469
|
-
systemMessage: '🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).',
|
|
470
|
-
}));
|
|
471
|
-
} catch { /* best effort */ }
|
|
503
|
+
pushBanner('🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).');
|
|
472
504
|
}
|
|
473
505
|
|
|
474
506
|
// F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
|
|
@@ -490,11 +522,7 @@ async function main() {
|
|
|
490
522
|
enabled: coldStartCfg.enabled !== false,
|
|
491
523
|
});
|
|
492
524
|
if (decision.shouldEmit) {
|
|
493
|
-
|
|
494
|
-
console.log(JSON.stringify({
|
|
495
|
-
systemMessage: decision.bannerLines.join('\n'),
|
|
496
|
-
}));
|
|
497
|
-
} catch { /* best effort — stdout may be closed */ }
|
|
525
|
+
pushBanner(decision.bannerLines.join('\n'));
|
|
498
526
|
if (decision.markerPath) {
|
|
499
527
|
await consumeMarker(decision.markerPath).catch(() => false);
|
|
500
528
|
}
|
|
@@ -592,17 +620,61 @@ async function main() {
|
|
|
592
620
|
// Append a peer line to the host banner when a banner was already emitted
|
|
593
621
|
// and at least one peer is live on this host.
|
|
594
622
|
if (bannerData && peers.length > 0) {
|
|
623
|
+
// #1052 A3a — split peers by the axis that actually decides behaviour.
|
|
624
|
+
//
|
|
625
|
+
// `.claude/rules/parallel-sessions.md` defines the operator-session axis by
|
|
626
|
+
// the WORKING COPY, not by reachability: a peer in this checkout contends
|
|
627
|
+
// for one git index, one filesystem, one STATE.md, and can hold the
|
|
628
|
+
// wave-scope guard. A peer in another repo contends only for host capacity.
|
|
629
|
+
// Those are different problems and the old single-line summary blurred them
|
|
630
|
+
// into one comma-separated list sorted by nothing.
|
|
631
|
+
//
|
|
632
|
+
// Correlate on `repo_path_hash` rather than `repo_name`: a sibling worktree
|
|
633
|
+
// of this repo carries a DIFFERENT working copy (and a different hash) while
|
|
634
|
+
// often sharing a similar name, and it is the checkout — not the name — that
|
|
635
|
+
// can collide. Falls back to name comparison only if the hash is missing.
|
|
636
|
+
let selfRepoHash = null;
|
|
637
|
+
try { selfRepoHash = repoPathHash(projectRoot); } catch { /* best effort */ }
|
|
638
|
+
const sameCopy = peers.filter((p) =>
|
|
639
|
+
selfRepoHash && p.repo_path_hash ? p.repo_path_hash === selfRepoHash : false);
|
|
640
|
+
const elsewhere = peers.filter((p) => !sameCopy.includes(p));
|
|
641
|
+
|
|
595
642
|
const threshold = await peerWarnThreshold(projectRoot);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
643
|
+
// WARN on any peer in THIS working copy — one is already enough to collide
|
|
644
|
+
// — or on host-wide count crossing the configured threshold.
|
|
645
|
+
const icon = sameCopy.length > 0 || peers.length >= threshold ? '⚠️ ' : '';
|
|
646
|
+
|
|
647
|
+
// Semantic ids are NOT unique — several live sessions routinely share e.g.
|
|
648
|
+
// `main-2026-08-21-session-2`, which makes a bare semantic label unusable
|
|
649
|
+
// for addressing a specific peer. Append a short uuid discriminator so two
|
|
650
|
+
// rows can be told apart (and so the id can be pasted into a lookup).
|
|
651
|
+
const fmt = (p) => {
|
|
652
|
+
const label = p.semantic_session_id ?? p.session_id ?? 'unknown';
|
|
653
|
+
const disc = p.session_id ? `#${String(p.session_id).slice(0, 8)}` : '';
|
|
654
|
+
return `${label}${disc}:${p.branch ?? 'unknown'}:wave-${p.current_wave ?? 0}`;
|
|
655
|
+
};
|
|
656
|
+
pushBanner(`${icon}👥 Peers: ${peers.length} live on this host`);
|
|
657
|
+
if (sameCopy.length > 0) {
|
|
658
|
+
const list = sameCopy.slice(0, 3).map(fmt).join(', ');
|
|
659
|
+
const more = sameCopy.length > 3 ? ` +${sameCopy.length - 3} more` : '';
|
|
660
|
+
// This is the line that matters. A peer here means PSA-002 territory:
|
|
661
|
+
// it may hold .claude/wave-scope.json, carry uncommitted work in files
|
|
662
|
+
// you are about to edit, or switch the branch under you.
|
|
663
|
+
pushBanner(` ⚠️ ${sameCopy.length} in THIS working copy — coordinate before editing (${list}${more})`);
|
|
664
|
+
}
|
|
665
|
+
if (elsewhere.length > 0) {
|
|
666
|
+
const list = elsewhere
|
|
667
|
+
.slice(0, 4)
|
|
668
|
+
.map((p) => `${p.repo_name ?? 'unknown'}:${p.branch ?? 'unknown'}`)
|
|
669
|
+
.join(', ');
|
|
670
|
+
const more = elsewhere.length > 4 ? ` +${elsewhere.length - 4} more` : '';
|
|
671
|
+
pushBanner(` other repos (host capacity only): ${list}${more}`);
|
|
672
|
+
}
|
|
673
|
+
// #1052 A3a acceptance criterion: state the limit, never imply a status we
|
|
674
|
+
// cannot read. `ListAgents` is a MODEL-side tool; this hook is a Node
|
|
675
|
+
// process and structurally cannot call it, so busy/waiting/idle is
|
|
676
|
+
// unavailable here. The coordinator half (A3b) overlays it later.
|
|
677
|
+
pushBanner(' (registry view: repo/branch/wave. Liveness (busy/idle) is model-side — not available in this hook.)');
|
|
606
678
|
}
|
|
607
679
|
|
|
608
680
|
// Epic #583 W3-P3 — Mechanical peer-detection banner (independent of the
|
|
@@ -632,10 +704,7 @@ async function main() {
|
|
|
632
704
|
.map((p) => `${p.sessionId}:${p.mode ?? 'session'}`)
|
|
633
705
|
.join(', ');
|
|
634
706
|
const overflow = mechanicalPeers.length > 3 ? ` +${mechanicalPeers.length - 3} more` : '';
|
|
635
|
-
|
|
636
|
-
try {
|
|
637
|
-
console.log(JSON.stringify({ systemMessage: mechanicalLine }));
|
|
638
|
-
} catch { /* best effort */ }
|
|
707
|
+
pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in same repo (${summary}${overflow})`);
|
|
639
708
|
}
|
|
640
709
|
} catch { /* best effort — banner is informational, never blocks */ }
|
|
641
710
|
}
|
|
@@ -652,9 +721,27 @@ async function main() {
|
|
|
652
721
|
payload.ram_free_gb = bannerData.resources.ram_free_gb;
|
|
653
722
|
payload.cpu_load_pct = bannerData.resources.cpu_load_pct;
|
|
654
723
|
payload.claude_processes_count = bannerData.resources.claude_processes_count;
|
|
724
|
+
// #1089 — record the signals the verdict is actually computed from, so the
|
|
725
|
+
// firing rate of each rule class is measurable AFTER the fact.
|
|
726
|
+
//
|
|
727
|
+
// This is the defect that let the old rule set fire on 99.0% of starts for
|
|
728
|
+
// four months undetected: `resource_verdict` was written to sessions.jsonl
|
|
729
|
+
// for exactly 15 of 1734 sessions, all inside one week in April 2026, and
|
|
730
|
+
// the fields logged here were the two MISLEADING ones (`ram_free_gb`,
|
|
731
|
+
// `claude_processes_count`) — so even the surviving telemetry could not
|
|
732
|
+
// have falsified the thresholds. The three added below are the ones the
|
|
733
|
+
// rules now judge on. `.claude/rules/host-resources.md` HR-005 turns them
|
|
734
|
+
// into a standing 10%-firing-rate audit.
|
|
735
|
+
payload.ram_available_gb = bannerData.resources.ram_available_gb ?? null;
|
|
736
|
+
payload.memory_pressure_pct_free = bannerData.resources.memory_pressure_pct_free ?? null;
|
|
737
|
+
payload.peer_sessions_count = bannerData.resources.peer_sessions_count ?? null;
|
|
655
738
|
}
|
|
656
739
|
await emitEvent('orchestrator.session.started', payload);
|
|
657
740
|
|
|
741
|
+
// Single flush — see the bannerLines docstring for why this must stay the
|
|
742
|
+
// only stdout write in the hook.
|
|
743
|
+
flushBanner();
|
|
744
|
+
|
|
658
745
|
// Size-based rotation of events.jsonl (#251). Session-start is the single
|
|
659
746
|
// rotation trigger — per-append overhead is rejected design. Any failure
|
|
660
747
|
// is swallowed: rotation must NEVER block the hook.
|
|
@@ -681,6 +768,10 @@ async function main() {
|
|
|
681
768
|
}
|
|
682
769
|
|
|
683
770
|
// Top-level guard — always exit 0 (non-blocking informational hook).
|
|
771
|
+
// flushBanner() runs here too so a throw partway through main() still surfaces
|
|
772
|
+
// whatever was already collected; it is idempotent, so the normal path (which
|
|
773
|
+
// flushes at the end of main) does not double-emit.
|
|
684
774
|
main().catch(() => {}).finally(() => {
|
|
775
|
+
flushBanner();
|
|
685
776
|
process.exit(0);
|
|
686
777
|
});
|