session-orchestrator 3.22.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- 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/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- 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/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- 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/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- 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 +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/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 +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
package/hooks/on-session-end.mjs
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* `session.started` had no terminal partner.
|
|
11
11
|
*
|
|
12
12
|
* JSONL format (`.orchestrator/metrics/events.jsonl`):
|
|
13
|
-
* {"timestamp":<ISO>,"event":"orchestrator.session.ended","session_id":"...","reason":"<reason>","duration_ms":<int>}
|
|
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.
|
|
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.)
|
|
14
20
|
*
|
|
15
21
|
* Exit codes: 0 always (informational hook — must never block session teardown).
|
|
16
22
|
* stdin: optional JSON { hook_event_name:"SessionEnd", session_id?, reason?, cwd? }.
|
|
@@ -30,15 +36,22 @@ if (!shouldRunHook('on-session-end')) process.exit(0);
|
|
|
30
36
|
|
|
31
37
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
32
38
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
33
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
backfillAbandonedSession,
|
|
41
|
+
backfillCompletedFromStateMd,
|
|
42
|
+
} from '../scripts/lib/session-close-backfill.mjs';
|
|
34
43
|
import {
|
|
35
44
|
readLockDetailed,
|
|
36
45
|
release,
|
|
37
46
|
loadOwnerProof,
|
|
38
47
|
OWNER_PROOF_RELPATH,
|
|
39
48
|
} from '../scripts/lib/session-lock.mjs';
|
|
49
|
+
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
40
50
|
import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
51
|
+
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
52
|
+
import { flush } from '../scripts/lib/telemetry/sync.mjs';
|
|
41
53
|
import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
|
|
54
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
42
55
|
|
|
43
56
|
// ---------------------------------------------------------------------------
|
|
44
57
|
// stdin reading (inline — SessionEnd hooks exit 0 always, never deny)
|
|
@@ -72,11 +85,29 @@ async function readStdinJson() {
|
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/**
|
|
75
|
-
* Resolve this session's id + duration + semantic id.
|
|
76
|
-
* otherwise fall back to
|
|
77
|
-
* on-session-start.mjs).
|
|
78
|
-
* is
|
|
79
|
-
* mismatched / unknown session.
|
|
88
|
+
* Resolve this session's id + duration + semantic id. A stdin session_id wins
|
|
89
|
+
* ONLY when it parses as a UUID; otherwise fall back to
|
|
90
|
+
* `.orchestrator/current-session.json` (written by on-session-start.mjs).
|
|
91
|
+
* duration_ms is only computed when the ENDING session is the one recorded in
|
|
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`).
|
|
96
|
+
*
|
|
97
|
+
* #1091 / Kanevry#66 — WRITER/READER SYMMETRY. `on-session-start.mjs`
|
|
98
|
+
* (`resolveSessionId`, :316-317) accepts a stdin raw id only when
|
|
99
|
+
* `parseSessionId(fromStdin)?.format === 'uuid'` and otherwise mints a
|
|
100
|
+
* `randomUUID()`; `current-session.json`, `session.lock` and the host registry
|
|
101
|
+
* are therefore ALWAYS keyed by a UUID. This reader used to accept ANY
|
|
102
|
+
* non-empty stdin string, so a harness that passed a non-UUID id
|
|
103
|
+
* (`{"session_id":"not-a-uuid"}`) resolved a key that matches nothing written
|
|
104
|
+
* at start: the raw-ID ownership compare below (:330) fails, the lock is
|
|
105
|
+
* neither released nor reconciled, and it LEAKS until its TTL expires. Same
|
|
106
|
+
* for `deregisterSelf()`, whose registry file is named after the id
|
|
107
|
+
* `registerSelf()` used. Mirroring the writer's rule here makes the fallback
|
|
108
|
+
* (which reads exactly those artifacts) the single source of the identity.
|
|
109
|
+
* The ownership compare itself stays an exact string `===` — this changes
|
|
110
|
+
* WHICH id is compared, never HOW.
|
|
80
111
|
*
|
|
81
112
|
* `semanticSessionId` is read from current-session.json (present since #587):
|
|
82
113
|
* it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
|
|
@@ -92,13 +123,42 @@ async function readStdinJson() {
|
|
|
92
123
|
* session therefore resolves `semanticSessionId: null` rather than inheriting
|
|
93
124
|
* another live session's backfill identity.
|
|
94
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
|
+
*
|
|
95
153
|
* @param {object|null} input
|
|
96
154
|
* @param {string} projectRoot
|
|
97
|
-
* @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}>}
|
|
98
156
|
*/
|
|
99
157
|
async function resolveSession(input, projectRoot) {
|
|
100
158
|
const fromStdin = input?.session_id ?? input?.sessionId ?? null;
|
|
101
|
-
|
|
159
|
+
// UUID-only, exactly as the writer decides it (see the docblock above).
|
|
160
|
+
const rawStdinId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
|
|
161
|
+
let sessionId = rawStdinId;
|
|
102
162
|
|
|
103
163
|
let recordedId = null;
|
|
104
164
|
let semanticSessionId = null;
|
|
@@ -121,13 +181,17 @@ async function resolveSession(input, projectRoot) {
|
|
|
121
181
|
}
|
|
122
182
|
} catch { /* missing or unparseable is fine */ }
|
|
123
183
|
|
|
184
|
+
// Actor-identity fallback ONLY — see the docblock's asymmetry note. This
|
|
185
|
+
// value must NEVER feed the ownership predicate below.
|
|
124
186
|
if (sessionId === null) sessionId = recordedId;
|
|
125
187
|
|
|
126
|
-
// Only trust the recorded start time when the ending session IS the recorded
|
|
127
|
-
|
|
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;
|
|
128
192
|
const durationMs = startedAtMs !== null && isRecordedSession
|
|
129
193
|
? Math.max(0, Date.now() - startedAtMs)
|
|
130
|
-
:
|
|
194
|
+
: null;
|
|
131
195
|
|
|
132
196
|
// #863 defect (c) — same guard as durationMs above: only surface the
|
|
133
197
|
// recorded semantic id when THIS ending session is genuinely the one
|
|
@@ -135,7 +199,350 @@ async function resolveSession(input, projectRoot) {
|
|
|
135
199
|
// contamination scenario this closes.
|
|
136
200
|
const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
|
|
137
201
|
|
|
138
|
-
|
|
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
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* POST budget for the close-time telemetry flush (ms).
|
|
216
|
+
*
|
|
217
|
+
* NAMED CEILING, and the reason it is BELOW the module default POST_TIMEOUT_MS
|
|
218
|
+
* (3000): the two timeouts fail differently.
|
|
219
|
+
*
|
|
220
|
+
* - This one expiring is LOSSLESS. `flush()` catches the abort and routes the
|
|
221
|
+
* record into the bounded offline queue; the daily fallback
|
|
222
|
+
* (`shouldDailyFlush`) drains it on a later session.
|
|
223
|
+
* - The HARNESS timeout expiring is LOSSY. Claude Code kills the hook process
|
|
224
|
+
* mid-flight, so the enqueue never runs and the record is simply gone —
|
|
225
|
+
* which is the exact failure #1138 exists to remove.
|
|
226
|
+
*
|
|
227
|
+
* So the internal bound must stay comfortably under the harness bound, not
|
|
228
|
+
* merely below it. Budget (hooks/hooks.json `SessionEnd.timeout: 10` s, raised
|
|
229
|
+
* from 5 with this change): up to 500 ms stdin + a backfill measured at a
|
|
230
|
+
* ~845 ms median (its TAIL, not its median, is what would collide) + lock
|
|
231
|
+
* release + deregistration, then this 2 s. That leaves several seconds of slack
|
|
232
|
+
* for the backfill tail while still covering a normal round-trip many times
|
|
233
|
+
* over.
|
|
234
|
+
*
|
|
235
|
+
* Revisit if the hooks.json SessionEnd timeout changes — the pin lives in
|
|
236
|
+
* tests/hooks/on-session-end.test.mjs.
|
|
237
|
+
*/
|
|
238
|
+
const TELEMETRY_FLUSH_TIMEOUT_MS = 2000;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read `persistence` from the repo's Session Config. Defaults to `true` — the
|
|
242
|
+
* same default `scripts/lib/config.mjs` applies — so an unreadable or absent
|
|
243
|
+
* CLAUDE.md (or its Codex alias AGENTS.md) never silently disables the flush.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} projectRoot
|
|
246
|
+
* @returns {Promise<boolean>}
|
|
247
|
+
*/
|
|
248
|
+
async function readPersistence(projectRoot) {
|
|
249
|
+
try {
|
|
250
|
+
const md = await readConfigFile(projectRoot);
|
|
251
|
+
return parseSessionConfig(md).persistence !== false;
|
|
252
|
+
} catch {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reduce a `flush()` result to the two-field breadcrumb the event carries.
|
|
259
|
+
*
|
|
260
|
+
* `reason` is normalised to its head token because `flush()` may return
|
|
261
|
+
* `build-error: <message>`, and a raw error message is unbounded free text in a
|
|
262
|
+
* stream whose whole purpose is aggregation by class.
|
|
263
|
+
*
|
|
264
|
+
* @param {{sent?: boolean, queued?: boolean, reason?: string}|null|undefined} res
|
|
265
|
+
* @returns {{outcome: 'sent'|'queued'|'gated'|'skipped', reason: string}}
|
|
266
|
+
*/
|
|
267
|
+
function classifyFlush(res) {
|
|
268
|
+
const reason = String(res?.reason ?? 'unknown').split(':')[0];
|
|
269
|
+
if (res?.sent === true) return { outcome: 'sent', reason };
|
|
270
|
+
if (res?.queued === true) return { outcome: 'queued', reason };
|
|
271
|
+
if (reason === 'gated') return { outcome: 'gated', reason };
|
|
272
|
+
return { outcome: 'skipped', reason };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* #1138 — the MECHANICAL telemetry flush.
|
|
277
|
+
*
|
|
278
|
+
* Until now the close-time flush existed only as prose in
|
|
279
|
+
* `skills/session-end/SKILL.md` § Phase 3.45, i.e. it ran only when the
|
|
280
|
+
* coordinator LLM happened to execute that phase. Measured 2026-08-23: 588
|
|
281
|
+
* session closes across 13 repos produced 82 ingest records (~14%). A hook is
|
|
282
|
+
* the only caller that fires on EVERY close, including the ones that never
|
|
283
|
+
* reach `/close` at all.
|
|
284
|
+
*
|
|
285
|
+
* Strictly best-effort: never throws, and bounded by TELEMETRY_FLUSH_TIMEOUT_MS.
|
|
286
|
+
* The consent gate lives INSIDE `flush()` (`resolveConsent()` is its first
|
|
287
|
+
* statement) — this function deliberately does not re-implement it, so there is
|
|
288
|
+
* exactly one place where "may we send?" is decided.
|
|
289
|
+
*
|
|
290
|
+
* Always emits `orchestrator.telemetry.flush` with `{ outcome, reason }` and
|
|
291
|
+
* NOTHING else — no payload, no anon_id. Per `.claude/rules/host-resources.md`
|
|
292
|
+
* HR-105, a mechanism whose firing rate nothing records cannot be falsified;
|
|
293
|
+
* this event is what makes the flush rate measurable next time.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} projectRoot
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
async function flushTelemetry(projectRoot) {
|
|
299
|
+
let result;
|
|
300
|
+
try {
|
|
301
|
+
result = (await readPersistence(projectRoot))
|
|
302
|
+
? classifyFlush(await flush({
|
|
303
|
+
metricsDir: path.join(projectRoot, '.orchestrator', 'metrics'),
|
|
304
|
+
timeoutMs: TELEMETRY_FLUSH_TIMEOUT_MS,
|
|
305
|
+
}))
|
|
306
|
+
// `persistence: false` means this session leaves no durable local trace;
|
|
307
|
+
// a telemetry ping is a durable record too, so it honours the same switch.
|
|
308
|
+
: { outcome: 'skipped', reason: 'persistence-disabled' };
|
|
309
|
+
} catch {
|
|
310
|
+
result = { outcome: 'skipped', reason: 'error' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// `result` is passed through verbatim: it holds EXACTLY {outcome, reason}, so
|
|
314
|
+
// no payload field can leak into the event by accident.
|
|
315
|
+
try {
|
|
316
|
+
await emitEvent('orchestrator.telemetry.flush', result);
|
|
317
|
+
} catch { /* observability is best-effort */ }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* #1068 AC2 — emit ONE canonically queryable outcome per backfill attempt.
|
|
322
|
+
*
|
|
323
|
+
* Until now a backfill result reached exactly one place:
|
|
324
|
+
* `.orchestrator/metrics/session-close-backfill.log`, a side-log no lifecycle
|
|
325
|
+
* consumer reads and no staleness/integrity check joins against. The event
|
|
326
|
+
* stream is where every other lifecycle fact already lives, so the outcome goes
|
|
327
|
+
* there too — carrying IDENTITY (`session_id` raw UUID + the record id the
|
|
328
|
+
* backfill acted on + the attested semantic id), ACTION, and, when the action
|
|
329
|
+
* was an error, its REASON. The side-log is unchanged and remains the verbose
|
|
330
|
+
* copy; this is the queryable one.
|
|
331
|
+
*
|
|
332
|
+
* `kind` distinguishes the two backfill classes, which share the log file and
|
|
333
|
+
* would otherwise be indistinguishable in aggregate: `'abandoned'`
|
|
334
|
+
* (`backfillAbandonedSession`) vs `'state-md-completed'`
|
|
335
|
+
* (`backfillCompletedFromStateMd`).
|
|
336
|
+
*
|
|
337
|
+
* Every id key is OMITTED when unknown — never `""`, never a guess (#1068 AC1).
|
|
338
|
+
* Best-effort throughout: observability must never block teardown.
|
|
339
|
+
*
|
|
340
|
+
* @param {'abandoned'|'state-md-completed'} kind
|
|
341
|
+
* @param {{action?: string, sessionId?: string|null, supersedes?: string, error?: string}|null|undefined} result
|
|
342
|
+
* @param {{sessionId: string|null, semanticSessionId: string|null}} ids
|
|
343
|
+
* @returns {Promise<void>}
|
|
344
|
+
*/
|
|
345
|
+
async function emitBackfillOutcome(kind, result, { sessionId, semanticSessionId }) {
|
|
346
|
+
try {
|
|
347
|
+
const recordId = typeof result?.sessionId === 'string' ? result.sessionId : null;
|
|
348
|
+
await emitEvent('orchestrator.session.backfill_completed', {
|
|
349
|
+
kind,
|
|
350
|
+
action: typeof result?.action === 'string' ? result.action : 'unknown',
|
|
351
|
+
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
352
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
353
|
+
...(recordId !== null ? { record_id: recordId } : {}),
|
|
354
|
+
...(typeof result?.supersedes === 'string' ? { supersedes: result.supersedes } : {}),
|
|
355
|
+
...(typeof result?.error === 'string' ? { reason: result.error } : {}),
|
|
356
|
+
});
|
|
357
|
+
} catch { /* observability is best-effort */ }
|
|
358
|
+
}
|
|
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
|
+
}
|
|
139
546
|
}
|
|
140
547
|
|
|
141
548
|
// ---------------------------------------------------------------------------
|
|
@@ -148,16 +555,56 @@ async function main() {
|
|
|
148
555
|
|
|
149
556
|
const reason =
|
|
150
557
|
typeof input?.reason === 'string' && input.reason.length > 0 ? input.reason : 'other';
|
|
151
|
-
const { sessionId, semanticSessionId, durationMs } =
|
|
558
|
+
const { sessionId, semanticSessionId, durationMs, isRecordedSession, rawStdinId } =
|
|
559
|
+
await resolveSession(input, projectRoot);
|
|
152
560
|
|
|
153
561
|
// Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
|
|
154
562
|
// JSONL record AND fires the optional Clank webhook with the SAME event name.
|
|
155
|
-
|
|
156
|
-
|
|
563
|
+
//
|
|
564
|
+
// #1068 AC1 — the terminal event carries the raw UUID *and* the semantic id
|
|
565
|
+
// whenever the latter is ATTESTED, so a lifecycle outcome is joinable by
|
|
566
|
+
// identity from events.jsonl alone (previously only `session.lock.acquired`
|
|
567
|
+
// carried both, and only ~1/3 of sessions emit one). The key is OMITTED, never
|
|
568
|
+
// written as `""` or `null`, when `resolveSession()` could not attest it —
|
|
569
|
+
// "identity unresolved" must stay visibly unresolved rather than become a
|
|
570
|
+
// guessed id (#1068 AC1's explicit "niemals eine geratene ID"). Note the
|
|
571
|
+
// attestation bar is the #863 defect (c) guard inside `resolveSession()`: an
|
|
572
|
+
// ending session that is NOT the one current-session.json describes resolves
|
|
573
|
+
// `semanticSessionId: null` and therefore emits no key here.
|
|
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,
|
|
157
589
|
reason,
|
|
158
|
-
|
|
590
|
+
rawStdinId,
|
|
159
591
|
});
|
|
160
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
|
+
|
|
161
608
|
// -------------------------------------------------------------------------
|
|
162
609
|
// C1 (#724) — Close-through backfill + deterministic lock release.
|
|
163
610
|
// Both are STRICTLY best-effort: a SessionEnd hook must never block teardown
|
|
@@ -172,7 +619,21 @@ async function main() {
|
|
|
172
619
|
// historical migration CLI) is NEVER passed here: a foreign lock that is live
|
|
173
620
|
// at hook-time is, by definition, a real active session, not stale history.
|
|
174
621
|
try {
|
|
175
|
-
await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
|
|
622
|
+
const res = await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
|
|
623
|
+
await emitBackfillOutcome('abandoned', res, { sessionId, semanticSessionId });
|
|
624
|
+
} catch { /* best-effort — never block teardown */ }
|
|
625
|
+
|
|
626
|
+
// (a2) #429 — STATE.md `status: completed` self-heal. Orthogonal to (a)
|
|
627
|
+
// above and to THIS session's own id: it reads STATE.md directly and
|
|
628
|
+
// repairs a PAST session whose `status: completed` was set (by hand or
|
|
629
|
+
// otherwise) without session-end's Phase 3.7 ever writing the matching
|
|
630
|
+
// sessions.jsonl record — the exact state `commands/close.md`'s
|
|
631
|
+
// Pre-Check then reads as "already finalized" forever after. Cheap
|
|
632
|
+
// no-op on the overwhelmingly common path (STATE.md status is
|
|
633
|
+
// 'active'/'paused'/'idle', or the record already exists).
|
|
634
|
+
try {
|
|
635
|
+
const res = await backfillCompletedFromStateMd({ repoRoot: projectRoot });
|
|
636
|
+
await emitBackfillOutcome('state-md-completed', res, { sessionId, semanticSessionId });
|
|
176
637
|
} catch { /* best-effort — never block teardown */ }
|
|
177
638
|
|
|
178
639
|
// (b) Deterministic lock release — ONLY a lock whose raw/native session_id
|
|
@@ -276,7 +737,7 @@ async function main() {
|
|
|
276
737
|
await emitEvent('orchestrator.session.lock.released', {
|
|
277
738
|
session_id: sessionId,
|
|
278
739
|
lock_session_id: lock.session_id,
|
|
279
|
-
semantic_session_id: semanticSessionId,
|
|
740
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
280
741
|
end_reason: reason,
|
|
281
742
|
caller: 'on-session-end',
|
|
282
743
|
outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
|
|
@@ -348,6 +809,12 @@ async function main() {
|
|
|
348
809
|
logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
|
|
349
810
|
}
|
|
350
811
|
}
|
|
812
|
+
|
|
813
|
+
// (d) #1138 — mechanical telemetry flush. Deliberately LAST: it is the only
|
|
814
|
+
// step here that may touch the network, so every local-fs guarantee above
|
|
815
|
+
// (backfill, lock release, deregistration) is already durable before the
|
|
816
|
+
// hook spends any of its remaining budget on a POST.
|
|
817
|
+
await flushTelemetry(projectRoot);
|
|
351
818
|
}
|
|
352
819
|
|
|
353
820
|
// Exit 0 always — informational hook must never block session teardown.
|