session-orchestrator 3.17.0 → 3.19.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/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -221,9 +221,27 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
|
|
|
221
221
|
function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
222
222
|
const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
|
|
223
223
|
const startedMs = Date.parse(startedIso);
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
224
|
+
// completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
|
|
225
|
+
// A fabricated `nowMs` produced ~64h of phantom runtime on real records
|
|
226
|
+
// (e.g. main-2026-07-18-session-2: started 2026-07-18, "completed" 2026-07-21).
|
|
227
|
+
// Precedence, all events-derived — the schema requires a string, so `null`
|
|
228
|
+
// is not an option (session-schema/validator.mjs rejects non-string):
|
|
229
|
+
// 1. lastTerminalMs — a real STOPPED/ENDED event: the true end.
|
|
230
|
+
// 2. lastEventMs — last life-sign; an ESTIMATE (flagged), never the run time.
|
|
231
|
+
// 3. startedIso — no post-start event at all → duration 0 (flagged).
|
|
232
|
+
let completedEstimated = false;
|
|
233
|
+
let completedMs;
|
|
234
|
+
if (Number.isFinite(gathered.lastTerminalMs)) {
|
|
235
|
+
completedMs = gathered.lastTerminalMs;
|
|
236
|
+
} else if (Number.isFinite(gathered.lastEventMs)) {
|
|
237
|
+
completedMs = gathered.lastEventMs;
|
|
238
|
+
completedEstimated = true;
|
|
239
|
+
} else {
|
|
240
|
+
completedMs = startedMs;
|
|
241
|
+
completedEstimated = true;
|
|
242
|
+
}
|
|
243
|
+
// Guard the same monotonic invariant as before: never earlier than started_at.
|
|
244
|
+
const completedIso = new Date(Math.max(startedMs, completedMs)).toISOString();
|
|
227
245
|
|
|
228
246
|
let sessionType = 'housekeeping';
|
|
229
247
|
let inferred = true;
|
|
@@ -238,6 +256,10 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
|
238
256
|
const incomplete = ['total_waves', 'waves', 'agent_summary', 'total_agents', 'total_files_changed'];
|
|
239
257
|
if (!startedFound) incomplete.push('started_at');
|
|
240
258
|
if (!branchFound) incomplete.push('branch');
|
|
259
|
+
// completed_at was estimated from lastEventMs (or defaulted to started_at) —
|
|
260
|
+
// no terminal event was found, so mark it incomplete so downstream duration
|
|
261
|
+
// consumers can tell an events-attested end apart from an estimate (#914 R1).
|
|
262
|
+
if (completedEstimated) incomplete.push('completed_at');
|
|
241
263
|
|
|
242
264
|
const record = {
|
|
243
265
|
session_id: recordId,
|
|
@@ -261,6 +283,7 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
|
261
283
|
if (branchFound) record.branch = gathered.branch;
|
|
262
284
|
if (inferred) record._session_type_inferred = true;
|
|
263
285
|
if (synthetic) record._synthetic_session_id = true;
|
|
286
|
+
if (completedEstimated) record._completed_at_estimated = true;
|
|
264
287
|
return record;
|
|
265
288
|
}
|
|
266
289
|
|
|
@@ -296,6 +319,7 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
|
|
|
296
319
|
* { action: 'would-backfill', sessionId, record, deadByAge? } — dryRun only, not written
|
|
297
320
|
* { action: 'skipped-no-identifier' } — neither id known
|
|
298
321
|
* { action: 'skipped-already-recorded', sessionId } — already in sessions.jsonl
|
|
322
|
+
* { action: 'skipped-own-live-lock', sessionId } — candidate IS this live session (#863)
|
|
299
323
|
* { action: 'skipped-foreign-live-lock', sessionId, lockSessionId }
|
|
300
324
|
* { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
|
|
301
325
|
* { action: 'error', error, sessionId? } — any failure, swallowed
|
|
@@ -421,7 +445,14 @@ export async function backfillAbandonedSession({
|
|
|
421
445
|
if (dupe) return dupe;
|
|
422
446
|
}
|
|
423
447
|
|
|
424
|
-
// -- Liveness guard — never overwrite a FOREIGN live lock
|
|
448
|
+
// -- Liveness guard — never overwrite a FOREIGN live lock, and never ----
|
|
449
|
+
// record OUR OWN live lock as 'abandoned' (#863 defect 1). Before this
|
|
450
|
+
// fix, the guard below only ever ran when `foreign` was true — the
|
|
451
|
+
// "this candidate IS the currently-live session" branch fell straight
|
|
452
|
+
// through to synthesis + append, backfilling a session as 'abandoned'
|
|
453
|
+
// mere seconds after it started (observed on-disk: main-2026-07-21-
|
|
454
|
+
// session-2, started 13:58:28.189Z, recorded abandoned 13:58:31.065Z).
|
|
455
|
+
//
|
|
425
456
|
// deadByAge (#731): set when a foreign live lock was present but the
|
|
426
457
|
// candidate qualified for relaxation — surfaced on the final result so
|
|
427
458
|
// callers (the migration CLI's summary) can count relaxed backfills.
|
|
@@ -436,8 +467,32 @@ export async function backfillAbandonedSession({
|
|
|
436
467
|
const ownByUuid = Boolean(sessionId) && lock.session_id === sessionId;
|
|
437
468
|
const ownBySemantic =
|
|
438
469
|
(Boolean(semanticSessionId) && lock.semantic_session_id === semanticSessionId) ||
|
|
439
|
-
(Boolean(recordId) && lock.semantic_session_id === recordId)
|
|
440
|
-
|
|
470
|
+
(Boolean(recordId) && lock.semantic_session_id === recordId) ||
|
|
471
|
+
// #863 (d) — lock-shape trap: some on-disk locks store the semantic
|
|
472
|
+
// id directly in `session_id` with no separate `semantic_session_id`
|
|
473
|
+
// field at all (the "generated-semantic" acquisition path in
|
|
474
|
+
// on-session-start.mjs mints `session_id === the semantic id`, and
|
|
475
|
+
// bootstrapLock's v2 enrichment step — which would otherwise add
|
|
476
|
+
// `semantic_session_id` — never ran for that lock). Without this
|
|
477
|
+
// fallback, ownBySemantic is dead code for that shape and a
|
|
478
|
+
// genuinely-own lock is misclassified `foreign`, which can then be
|
|
479
|
+
// wrongly bypassed by the dead-by-age relaxation below despite
|
|
480
|
+
// being live right now.
|
|
481
|
+
(Boolean(semanticSessionId) && lock.session_id === semanticSessionId) ||
|
|
482
|
+
(Boolean(recordId) && lock.session_id === recordId);
|
|
483
|
+
const own = ownByUuid || ownBySemantic;
|
|
484
|
+
const foreign = !own;
|
|
485
|
+
|
|
486
|
+
// #863 defect 1 — an OWN lock that is still live means this session
|
|
487
|
+
// is actively running right now; it must never be recorded
|
|
488
|
+
// 'abandoned'. Runs BEFORE the foreign-live-lock guard below (which
|
|
489
|
+
// only ever fires when `foreign` is true). A STALE own lock
|
|
490
|
+
// (isLockLive === false) falls through unchanged — this is a
|
|
491
|
+
// liveness gate, not a blanket own-session off-switch.
|
|
492
|
+
if (own && isLockLive(lock, nowMs)) {
|
|
493
|
+
return { action: 'skipped-own-live-lock', sessionId: recordId };
|
|
494
|
+
}
|
|
495
|
+
|
|
441
496
|
if (foreign && isLockLive(lock, nowMs)) {
|
|
442
497
|
const relaxed = isCandidateDeadByAge({
|
|
443
498
|
relaxDeadByAge,
|
|
@@ -276,6 +276,7 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
276
276
|
ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
|
|
277
277
|
minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
|
|
278
278
|
minInsightChars: cfg?.reconcile?.['min-insight-chars'] ?? undefined,
|
|
279
|
+
maxProposalsPerRun: cfg?.reconcile?.['max-proposals-per-run'] ?? undefined,
|
|
279
280
|
now: new Date(),
|
|
280
281
|
dryRun: true, // never write the candidate sidecar from the aggregator
|
|
281
282
|
});
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* session-id.mjs — Semantic session-ID generation and dual-format parsing.
|
|
3
3
|
*
|
|
4
4
|
* Public API:
|
|
5
|
-
* - resolveSemanticSessionId({ branch, mode, activeSessions, repoRoot,
|
|
5
|
+
* - resolveSemanticSessionId({ branch, mode, activeSessions, repoRoot, sources }): Promise<string>
|
|
6
6
|
* - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
|
|
7
|
+
* - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
|
|
7
8
|
* - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
|
|
8
9
|
* - UUID_V4_RE — regex for UUID-v4 format session IDs
|
|
9
10
|
*
|
|
@@ -19,20 +20,26 @@
|
|
|
19
20
|
* - resolveSemanticSessionId wraps its body in withStateMdLock so two
|
|
20
21
|
* concurrent preambles cannot assign duplicate n values (PSA-005).
|
|
21
22
|
* - parseSessionId is a pure synchronous function — no I/O, no side effects.
|
|
22
|
-
* - History-aware (#585): the n-increment consults
|
|
23
|
+
* - History-aware (#585): the n-increment consults four candidate sources:
|
|
23
24
|
* 1. activeSessions (the legacy source — live lockfiles + registry).
|
|
24
25
|
* 2. sessions.jsonl history (closed sessions; appended on session-end).
|
|
25
26
|
* 3. STATE.md frontmatter `session:` (last-resort survivor of crashed sessions).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* 4. events.jsonl `orchestrator.session.lock.acquired` (#952 Teil B —
|
|
28
|
+
* append-only mint-ledger; the only source written at CLAIM time).
|
|
29
|
+
* Sources 2-4 are DEFAULT_SESSION_ID_SOURCES — deferred readers invoked
|
|
30
|
+
* inside the existing withStateMdLock, so their visibility is consistent
|
|
31
|
+
* with the n-claim that follows. They are configured through the single
|
|
32
|
+
* `opts.sources` array (#956): opting out is passing a shorter array,
|
|
33
|
+
* overriding is passing your own reader, and adding a fifth source costs
|
|
34
|
+
* no new interface element. Source 1 stays a separate parameter — it is
|
|
35
|
+
* already-resolved data gathered BEFORE the lock, so it is deliberately
|
|
36
|
+
* not disguised as a deferred, lock-covered reader.
|
|
30
37
|
* - Reader helpers never throw: missing files, malformed JSONL lines, and
|
|
31
38
|
* unparseable frontmatter are all treated as "no signal" (empty/null).
|
|
32
39
|
* - Production code is silent: no console.log, no console.warn.
|
|
33
40
|
*/
|
|
34
41
|
|
|
35
|
-
import { readFile } from 'node:fs/promises';
|
|
42
|
+
import { open, readFile } from 'node:fs/promises';
|
|
36
43
|
import path from 'node:path';
|
|
37
44
|
|
|
38
45
|
import { withStateMdLock } from './session-lock.mjs';
|
|
@@ -139,6 +146,118 @@ async function readSessionIdsFromHistory(repoRoot) {
|
|
|
139
146
|
return ids;
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Event name emitted by `hooks/_lib/lock-bootstrap.mjs` on every successful
|
|
151
|
+
* session-lock acquisition — i.e. on every semantic-ID MINT.
|
|
152
|
+
* @type {string}
|
|
153
|
+
*/
|
|
154
|
+
const LOCK_ACQUIRED_EVENT = 'orchestrator.session.lock.acquired';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Byte window for the events.jsonl tail read (1 MiB).
|
|
158
|
+
*
|
|
159
|
+
* Sizing rationale: the match downstream is `date === today`, so the window
|
|
160
|
+
* only has to cover today's events. Worst observed single-day volume in this
|
|
161
|
+
* repo is ~164 KiB (measured 2026-07-31 over the live log), and rotation caps
|
|
162
|
+
* the file at `events-rotation.max-size-mb` (default 10 MiB). 1 MiB is ~6x the
|
|
163
|
+
* worst observed day and 10% of the rotation ceiling.
|
|
164
|
+
* @type {number}
|
|
165
|
+
*/
|
|
166
|
+
const EVENTS_TAIL_BYTES = 1024 * 1024;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Age cutoff for events considered by the reader (2 days).
|
|
170
|
+
*
|
|
171
|
+
* Anything older cannot influence a `date === today` match, so dropping it
|
|
172
|
+
* early keeps the candidate set small. Records whose timestamp is absent or
|
|
173
|
+
* unparseable are KEPT, never dropped — under-reporting a claimed n is exactly
|
|
174
|
+
* the failure this source exists to prevent (#952).
|
|
175
|
+
* @type {number}
|
|
176
|
+
*/
|
|
177
|
+
const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read minted semantic session IDs from
|
|
181
|
+
* `<repoRoot>/.orchestrator/metrics/events.jsonl`.
|
|
182
|
+
*
|
|
183
|
+
* Why this source exists (#952 Teil B): `sessions.jsonl` is written at
|
|
184
|
+
* session-END. A session that is killed before its SessionEnd hook fires never
|
|
185
|
+
* writes a record, so the `n` it consumed becomes invisible to every other
|
|
186
|
+
* source and the NEXT session mints the same ID. Proven collision (live ledger,
|
|
187
|
+
* 2026-07-29): `main-2026-07-29-session-2` was minted twice, 7h57m apart.
|
|
188
|
+
* `events.jsonl` is append-only and written at CLAIM time by
|
|
189
|
+
* `hooks/_lib/lock-bootstrap.mjs`, so a minted `n` survives a crash by
|
|
190
|
+
* construction.
|
|
191
|
+
*
|
|
192
|
+
* Read strategy: a bounded TAIL read (`EVENTS_TAIL_BYTES` from the end of the
|
|
193
|
+
* file), not a whole-file read — events.jsonl grows without bound between
|
|
194
|
+
* rotations (~1.9 MB at time of writing). When the window does not reach the
|
|
195
|
+
* start of the file, the first (possibly truncated) line is discarded.
|
|
196
|
+
*
|
|
197
|
+
* Robustness contract (mirrors `readSessionIdsFromHistory`):
|
|
198
|
+
* - Missing file (ENOENT) → returns [].
|
|
199
|
+
* - Permission/FS/read error → returns [] (fail-open; a telemetry read must
|
|
200
|
+
* never block the n-claim it feeds).
|
|
201
|
+
* - Malformed JSONL line → silently skipped (per-line try/catch).
|
|
202
|
+
* - Freshly rotated file → legitimately yields [] or few records. Not an
|
|
203
|
+
* error: the three other sources remain in play unchanged.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} repoRoot
|
|
206
|
+
* @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
|
|
207
|
+
*/
|
|
208
|
+
async function readSessionIdsFromEvents(repoRoot) {
|
|
209
|
+
const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
210
|
+
|
|
211
|
+
let text = '';
|
|
212
|
+
// Assigned in the try below; every path that reaches its read site has
|
|
213
|
+
// passed through that assignment (the catch returns early).
|
|
214
|
+
let windowIsPartial;
|
|
215
|
+
let handle = null;
|
|
216
|
+
try {
|
|
217
|
+
handle = await open(filePath, 'r');
|
|
218
|
+
const { size } = await handle.stat();
|
|
219
|
+
const start = size > EVENTS_TAIL_BYTES ? size - EVENTS_TAIL_BYTES : 0;
|
|
220
|
+
windowIsPartial = start > 0;
|
|
221
|
+
const length = size - start;
|
|
222
|
+
if (length > 0) {
|
|
223
|
+
const buf = Buffer.alloc(length);
|
|
224
|
+
const { bytesRead } = await handle.read(buf, 0, length, start);
|
|
225
|
+
text = buf.subarray(0, bytesRead).toString('utf8');
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
return [];
|
|
229
|
+
} finally {
|
|
230
|
+
if (handle !== null) await handle.close().catch(() => {});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lines = text.split(/\r?\n/);
|
|
234
|
+
// The window started mid-file, so line 0 may be a truncated record.
|
|
235
|
+
if (windowIsPartial) lines.shift();
|
|
236
|
+
|
|
237
|
+
const cutoff = Date.now() - EVENTS_MAX_AGE_MS;
|
|
238
|
+
const ids = [];
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
const trimmed = line.trim();
|
|
241
|
+
if (trimmed === '') continue;
|
|
242
|
+
// Cheap substring pre-filter: events.jsonl carries many event kinds and
|
|
243
|
+
// JSON.parse on every line of a 1 MiB window is the dominant cost.
|
|
244
|
+
if (!trimmed.includes(LOCK_ACQUIRED_EVENT)) continue;
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(trimmed);
|
|
248
|
+
} catch {
|
|
249
|
+
// Malformed line — skip silently (same contract as sessions.jsonl).
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (parsed?.event !== LOCK_ACQUIRED_EVENT) continue;
|
|
253
|
+
const ts = Date.parse(parsed.timestamp);
|
|
254
|
+
// Only drop on a PARSEABLE, definitely-stale timestamp (see EVENTS_MAX_AGE_MS).
|
|
255
|
+
if (Number.isFinite(ts) && ts < cutoff) continue;
|
|
256
|
+
if (typeof parsed.semantic_session_id === 'string') ids.push(parsed.semantic_session_id);
|
|
257
|
+
}
|
|
258
|
+
return ids;
|
|
259
|
+
}
|
|
260
|
+
|
|
142
261
|
/**
|
|
143
262
|
* Read the `session:` field from `<repoRoot>/.claude/STATE.md` (or `.codex`,
|
|
144
263
|
* `.cursor`, `.pi` — `resolveStateMdPath` picks the active/existing candidate).
|
|
@@ -170,10 +289,53 @@ async function readSessionIdFromStateMd(repoRoot) {
|
|
|
170
289
|
return typeof sessionField === 'string' ? sessionField : null;
|
|
171
290
|
}
|
|
172
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Shape adapter for the STATE.md source.
|
|
294
|
+
*
|
|
295
|
+
* STATE.md has exactly ONE `session:` slot, so its reader naturally returns
|
|
296
|
+
* `string|null` while every other source returns `string[]`. This adapter
|
|
297
|
+
* normalises it to the uniform source signature `(repoRoot) => Promise<string[]>`,
|
|
298
|
+
* which is what lets `sources` be a single homogeneous array. Before #956 the
|
|
299
|
+
* mismatch was special-cased twice at the merge site (a `.catch(() => null)`
|
|
300
|
+
* that differed from its siblings, plus a `stateMdId !== null` spread guard).
|
|
301
|
+
*
|
|
302
|
+
* @param {string} repoRoot
|
|
303
|
+
* @returns {Promise<string[]>} `[]` when absent/unreadable, else `[sessionId]`.
|
|
304
|
+
*/
|
|
305
|
+
async function readSessionIdsFromStateMd(repoRoot) {
|
|
306
|
+
const id = await readSessionIdFromStateMd(repoRoot);
|
|
307
|
+
return id === null ? [] : [id];
|
|
308
|
+
}
|
|
309
|
+
|
|
173
310
|
// ---------------------------------------------------------------------------
|
|
174
311
|
// Public API
|
|
175
312
|
// ---------------------------------------------------------------------------
|
|
176
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The default `sources` array for {@link resolveSemanticSessionId} — the three
|
|
316
|
+
* on-disk candidate-ID readers, in the order they were introduced:
|
|
317
|
+
*
|
|
318
|
+
* 1. `readSessionIdsFromHistory` — `.orchestrator/metrics/sessions.jsonl` (#585)
|
|
319
|
+
* 2. `readSessionIdsFromStateMd` — STATE.md frontmatter `session:` (#585)
|
|
320
|
+
* 3. `readSessionIdsFromEvents` — `.orchestrator/metrics/events.jsonl` (#952)
|
|
321
|
+
*
|
|
322
|
+
* Order is irrelevant to the result: aggregation is `max` over the UNION of all
|
|
323
|
+
* sources, so it is commutative and monotone — an additional source can only
|
|
324
|
+
* raise `n`, never lower it.
|
|
325
|
+
*
|
|
326
|
+
* Exported so callers can derive from it rather than restate it:
|
|
327
|
+
* - add a source: `sources: [...DEFAULT_SESSION_ID_SOURCES, myReader]`
|
|
328
|
+
* - drop a source: `sources: DEFAULT_SESSION_ID_SOURCES.filter(s => s !== …)`
|
|
329
|
+
* - legacy mode: `sources: []` (activeSessions only)
|
|
330
|
+
*
|
|
331
|
+
* @type {ReadonlyArray<(repoRoot: string) => Promise<string[]>>}
|
|
332
|
+
*/
|
|
333
|
+
export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
|
|
334
|
+
readSessionIdsFromHistory,
|
|
335
|
+
readSessionIdsFromStateMd,
|
|
336
|
+
readSessionIdsFromEvents,
|
|
337
|
+
]);
|
|
338
|
+
|
|
177
339
|
/**
|
|
178
340
|
* Parse a session ID string into a structured object.
|
|
179
341
|
*
|
|
@@ -228,23 +390,33 @@ export function parseSessionId(id) {
|
|
|
228
390
|
* `^[a-z0-9._/-]+-\d{4}-\d{2}-\d{2}-[a-z-]+-\d+$`
|
|
229
391
|
*
|
|
230
392
|
* Counter semantics (PRD §3.A P2 Ubiquitous + #585 history-aware extension):
|
|
231
|
-
* n = max(existing n values for same branch+date+mode across
|
|
393
|
+
* n = max(existing n values for same branch+date+mode across FOUR sources) + 1
|
|
232
394
|
* When no matching sessions exist anywhere → n = 1.
|
|
233
395
|
* Gaps are never filled: if existing n = [1, 3] the next is 4, not 2.
|
|
234
396
|
*
|
|
235
397
|
* Sources consulted (all merged into a single candidate set):
|
|
236
398
|
* A. opts.activeSessions — live sessions (lockfiles + host-wide registry).
|
|
237
|
-
* B. sessions.jsonl — closed-session history
|
|
238
|
-
* C. STATE.md `session:` — last-resort survivor
|
|
399
|
+
* B. sessions.jsonl — closed-session history.
|
|
400
|
+
* C. STATE.md `session:` — last-resort survivor.
|
|
401
|
+
* D. events.jsonl — mint ledger, written at CLAIM time.
|
|
239
402
|
*
|
|
240
|
-
*
|
|
403
|
+
* B, C and D are `opts.sources` (default: DEFAULT_SESSION_ID_SOURCES, i.e.
|
|
404
|
+
* all three ON) — historically only source A was consulted,
|
|
241
405
|
* which caused n to reset to 1 once the previous session deregistered itself
|
|
242
406
|
* (root-cause of duplicate-ID incidents documented in #585).
|
|
243
407
|
*
|
|
408
|
+
* Source D closes the residual hole (#952 Teil B): B is written at session-END
|
|
409
|
+
* and C has exactly ONE slot (an interleaved session of a different mode
|
|
410
|
+
* overwrites it, and the mode filter below then discards it). A session killed
|
|
411
|
+
* before its SessionEnd hook therefore leaves NO trace in A, B or C, and the
|
|
412
|
+
* next session re-mints its n. Proven live: `main-2026-07-29-session-2` was
|
|
413
|
+
* minted twice, 7h57m apart. D would have yielded maxN=2 → `session-3`.
|
|
414
|
+
*
|
|
244
415
|
* Concurrency safety (PSA-005):
|
|
245
|
-
* All
|
|
416
|
+
* All `sources` reads and the n-claim are wrapped in `withStateMdLock` so two
|
|
246
417
|
* concurrent preambles in parallel worktrees observe a consistent view and
|
|
247
|
-
* cannot assign the same n.
|
|
418
|
+
* cannot assign the same n. The #952 collision was NOT a concurrency defect —
|
|
419
|
+
* the lock held; the candidate set was incomplete.
|
|
248
420
|
*
|
|
249
421
|
* UUID-v4 entries (in any source) are silently dropped (parseSessionId returns
|
|
250
422
|
* format:'uuid' which the filter excludes). Malformed semantic-looking IDs are
|
|
@@ -258,21 +430,27 @@ export function parseSessionId(id) {
|
|
|
258
430
|
* @param {Array<{sessionId: string}>} [opts.activeSessions=[]] - Active sessions
|
|
259
431
|
* array from session-discovery. Each element must have a `.sessionId` string.
|
|
260
432
|
* Defaults to an empty array when omitted or undefined.
|
|
433
|
+
* Deliberately NOT part of `sources`: it is `{sessionId}` objects rather than
|
|
434
|
+
* strings, and it is already-resolved data gathered BEFORE the call (see
|
|
435
|
+
* `hooks/on-session-start.mjs` `deriveSemanticCandidate`), so it is the one
|
|
436
|
+
* source NOT covered by the lock's consistent-read guarantee. Wrapping it as
|
|
437
|
+
* `async () => data` would hide both facts.
|
|
261
438
|
* @param {string} [opts.repoRoot] - Absolute path to the repo root. Used by
|
|
262
|
-
* `withStateMdLock
|
|
439
|
+
* `withStateMdLock` and passed to every entry of `sources`.
|
|
263
440
|
* Defaults to `process.cwd()` when omitted.
|
|
264
|
-
* @param {
|
|
265
|
-
* sources
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
441
|
+
* @param {ReadonlyArray<(repoRoot: string) => Promise<string[]>>} [opts.sources=DEFAULT_SESSION_ID_SOURCES]
|
|
442
|
+
* The candidate-ID readers for sources B/C/D — the ONLY DI knob (#956).
|
|
443
|
+
* Each entry is a deferred thunk invoked with the effective repo root INSIDE
|
|
444
|
+
* `withStateMdLock`, and must resolve to an array of session-ID strings.
|
|
445
|
+
* Non-string and unknown-format entries are dropped downstream, so a reader
|
|
446
|
+
* may return raw IDs without pre-filtering.
|
|
447
|
+
* The default is {@link DEFAULT_SESSION_ID_SOURCES} =
|
|
448
|
+
* `[sessions.jsonl reader, STATE.md reader, events.jsonl reader]`.
|
|
449
|
+
* Opting a source out = passing a shorter array (`[]` for the pre-#585
|
|
450
|
+
* activeSessions-only behaviour); overriding one = passing your own function;
|
|
451
|
+
* adding a fifth = appending to the default. Each entry is individually
|
|
452
|
+
* error-swallowed (a throwing or rejecting source contributes `[]` and never
|
|
453
|
+
* blocks the n-claim), so one bad reader cannot fail the mint.
|
|
276
454
|
* @returns {Promise<string>} The next semantic session ID, e.g. "main-2026-05-27-deep-2".
|
|
277
455
|
* @throws {TypeError} When `branch` is missing, empty, or contains invalid characters.
|
|
278
456
|
* @throws {TypeError} When `mode` is missing, empty, or contains characters other than
|
|
@@ -284,7 +462,7 @@ export async function resolveSemanticSessionId({
|
|
|
284
462
|
mode,
|
|
285
463
|
activeSessions,
|
|
286
464
|
repoRoot,
|
|
287
|
-
|
|
465
|
+
sources,
|
|
288
466
|
} = {}) {
|
|
289
467
|
// Input validation — validate before acquiring the lock to fail fast.
|
|
290
468
|
if (!isValidBranch(branch)) {
|
|
@@ -298,30 +476,32 @@ export async function resolveSemanticSessionId({
|
|
|
298
476
|
);
|
|
299
477
|
}
|
|
300
478
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
const historyImpl = history?.readHistoryImpl ?? readSessionIdsFromHistory;
|
|
305
|
-
const stateMdImpl = history?.readStateMdSessionImpl ?? readSessionIdFromStateMd;
|
|
479
|
+
// Resolve the source set. An explicit array (including []) wins; anything
|
|
480
|
+
// else — omitted, undefined, non-array — falls back to the documented default.
|
|
481
|
+
const effectiveSources = Array.isArray(sources) ? sources : DEFAULT_SESSION_ID_SOURCES;
|
|
306
482
|
const effectiveRoot = repoRoot ?? process.cwd();
|
|
307
483
|
|
|
308
484
|
return withStateMdLock(repoRoot, async () => {
|
|
309
485
|
// Derive the current UTC date as YYYY-MM-DD.
|
|
310
486
|
const today = new Date().toISOString().slice(0, 10);
|
|
311
487
|
|
|
312
|
-
// Read
|
|
313
|
-
//
|
|
314
|
-
// never
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
488
|
+
// Read every source in parallel. Each is individually error-swallowed:
|
|
489
|
+
// the built-in readers already never throw, but a caller-supplied source
|
|
490
|
+
// might — and one bad reader must never block the n-claim it feeds. The
|
|
491
|
+
// Promise.resolve().then() wrapper also catches a SYNCHRONOUS throw, which
|
|
492
|
+
// a bare `.catch()` on the return value would not.
|
|
493
|
+
const perSourceIds = await Promise.all(
|
|
494
|
+
effectiveSources.map((read) =>
|
|
495
|
+
Promise.resolve()
|
|
496
|
+
.then(() => read(effectiveRoot))
|
|
497
|
+
.catch(() => []),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
319
500
|
|
|
320
501
|
// Build a single candidate stream. Duplicates are fine — Math.max handles them.
|
|
321
502
|
const candidateIds = [
|
|
322
503
|
...(activeSessions ?? []).map((s) => s?.sessionId),
|
|
323
|
-
...
|
|
324
|
-
...(stateMdId !== null ? [stateMdId] : []),
|
|
504
|
+
...perSourceIds.flat(),
|
|
325
505
|
];
|
|
326
506
|
|
|
327
507
|
// Match against (branch, date, mode) and project to n.
|