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
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
* from `hooks/on-session-end.mjs` and by the one-time historical migration CLI
|
|
14
14
|
* `scripts/backfill-abandoned-sessions.mjs`.
|
|
15
15
|
*
|
|
16
|
+
* A second, sibling export — `backfillCompletedFromStateMd` — closes a
|
|
17
|
+
* different gap (#429): `commands/close.md`'s Pre-Check treats STATE.md
|
|
18
|
+
* `status: completed` as proof the session-end writer already ran and refuses
|
|
19
|
+
* to invoke it again. When that status was set by hand (or by any path that
|
|
20
|
+
* never reached Phase 3.7), no sessions.jsonl record is EVER written — the
|
|
21
|
+
* Pre-Check keeps blocking the only thing that would normally create one.
|
|
22
|
+
* `backfillCompletedFromStateMd` reads STATE.md directly, and when its status
|
|
23
|
+
* is `completed` with no matching sessions.jsonl record, backfills one tagged
|
|
24
|
+
* `status: 'completed'` + `_backfill_source: 'state-md-completed'` (never
|
|
25
|
+
* `'abandoned'` — the session itself claims to have finished normally).
|
|
26
|
+
*
|
|
16
27
|
* ── ID BRIDGE ────────────────────────────────────────────────────────────────
|
|
17
28
|
* sessions.jsonl records are keyed by SEMANTIC ids (`main-2026-05-27-session-1`).
|
|
18
29
|
* events.jsonl carries the harness UUID on `session.started` / `stop` / `ended`.
|
|
@@ -25,6 +36,11 @@
|
|
|
25
36
|
* - No-throw: every path returns a structured `{ action, ... }` result; the
|
|
26
37
|
* hook must never be pushed past its teardown timeout by an exception.
|
|
27
38
|
* - Dedupe: never double-write a session already present in sessions.jsonl.
|
|
39
|
+
* - Supersede (#1068 AC3/AC4): the ONE exception to that dedupe — an
|
|
40
|
+
* authoritative `state-md-completed` record may be appended for an identity
|
|
41
|
+
* whose only entry is a backfilled `abandoned` STUB, carrying
|
|
42
|
+
* `supersedes: <stub id>`. Append-only: the stub is kept verbatim and
|
|
43
|
+
* readers take the NEWEST record for an id as the canonical one.
|
|
28
44
|
* - Liveness guard: never backfill over a FOREIGN live session.lock (PSA).
|
|
29
45
|
* - TOCTOU marker: an atomic `openSync(..., 'wx')` claim file keyed by the
|
|
30
46
|
* final id serialises concurrent backfill attempts (mirrors the
|
|
@@ -40,16 +56,17 @@ import path from 'node:path';
|
|
|
40
56
|
import crypto from 'node:crypto';
|
|
41
57
|
|
|
42
58
|
import { appendJsonl as defaultAppendJsonl } from './common.mjs';
|
|
59
|
+
import { parseSessionId } from './session-id.mjs';
|
|
43
60
|
import { readLock as defaultReadLock, isLockLive as defaultIsLockLive, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
44
61
|
import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
|
|
45
62
|
import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
|
|
63
|
+
import { resolveStateMdPath as defaultResolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
|
|
64
|
+
import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs';
|
|
46
65
|
|
|
47
66
|
// ---------------------------------------------------------------------------
|
|
48
67
|
// Constants
|
|
49
68
|
// ---------------------------------------------------------------------------
|
|
50
69
|
|
|
51
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
52
|
-
|
|
53
70
|
/** session_type enum accepted by the schema — lock.mode is coerced against it. */
|
|
54
71
|
const VALID_SESSION_TYPES = new Set(['feature', 'deep', 'housekeeping']);
|
|
55
72
|
|
|
@@ -66,9 +83,22 @@ const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.lo
|
|
|
66
83
|
// Helpers
|
|
67
84
|
// ---------------------------------------------------------------------------
|
|
68
85
|
|
|
69
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* True when `s` is an RFC 9562 UUID session id (any version 1-8, variant `10xx`).
|
|
88
|
+
*
|
|
89
|
+
* Delegates to `parseSessionId()` so the repo has exactly ONE UUID contract
|
|
90
|
+
* (`UUID_RE` in `scripts/lib/session-id.mjs`). This module previously carried a
|
|
91
|
+
* private, LOOSER copy that constrained neither the version nor the variant
|
|
92
|
+
* nibble, so a 36-char lookalike such as
|
|
93
|
+
* `xxxxxxxx-xxxx-0xxx-cxxx-xxxxxxxxxxxx` was classified as a harness UUID here
|
|
94
|
+
* while `hooks/on-session-start.mjs` — which gates on `parseSessionId()` —
|
|
95
|
+
* rejected it. The disagreement is exactly the ID-bridge this module depends
|
|
96
|
+
* on: a value the writer refused to use as a raw id was still treated here as
|
|
97
|
+
* one, sending the record down the UUID branch (lock-bridge + synthetic-id
|
|
98
|
+
* mint) instead of the semantic branch.
|
|
99
|
+
*/
|
|
70
100
|
export function isUuid(s) {
|
|
71
|
-
return
|
|
101
|
+
return parseSessionId(s)?.format === 'uuid';
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/** Filesystem-safe marker filename for an arbitrary session id. */
|
|
@@ -123,18 +153,33 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
|
|
|
123
153
|
|
|
124
154
|
let mode = null;
|
|
125
155
|
let semanticFromLock = null;
|
|
126
|
-
|
|
127
|
-
//
|
|
156
|
+
// #1167 — the SECOND semantic bridge. `orchestrator.session.ended` carries
|
|
157
|
+
// `semantic_session_id` alongside the raw UUID since #1068 AC1, but nothing
|
|
158
|
+
// read it: a session that LOST the lock-acquire race emits no lock.acquired,
|
|
159
|
+
// so the lock bridge above resolved null and the caller fell through to the
|
|
160
|
+
// synthetic-id mint — writing a SECOND `abandoned` stub for a session the
|
|
161
|
+
// SessionEnd hook had already recorded under its semantic id. Measured
|
|
162
|
+
// 2026-09-02 @ c3ab480: 8 such duplicate pairs in sessions.jsonl.
|
|
163
|
+
let semanticFromEvents = null;
|
|
164
|
+
|
|
165
|
+
// First pass — bridge the UUID set + carry mode + semantic id. lock.acquired
|
|
166
|
+
// is the original bridge; session.ended is the #1167 addition.
|
|
128
167
|
for (const ev of events) {
|
|
129
|
-
|
|
168
|
+
const isLock = ev.event === EVENT_LOCK_ACQUIRED;
|
|
169
|
+
const isEnded = ev.event === EVENT_ENDED && typeof ev.semantic_session_id === 'string';
|
|
170
|
+
if (!isLock && !isEnded) continue;
|
|
130
171
|
const matchesUuid = isUuid(sessionId) && ev.session_id === sessionId;
|
|
131
172
|
const matchesSemantic =
|
|
132
173
|
(semanticSessionId && ev.semantic_session_id === semanticSessionId) ||
|
|
133
174
|
(!isUuid(sessionId) && sessionId && ev.semantic_session_id === sessionId);
|
|
134
175
|
if (!matchesUuid && !matchesSemantic) continue;
|
|
135
176
|
if (typeof ev.session_id === 'string') uuids.add(ev.session_id);
|
|
136
|
-
if (
|
|
137
|
-
|
|
177
|
+
if (isLock) {
|
|
178
|
+
if (typeof ev.mode === 'string') mode = ev.mode;
|
|
179
|
+
if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
|
|
180
|
+
} else {
|
|
181
|
+
semanticFromEvents = ev.semantic_session_id;
|
|
182
|
+
}
|
|
138
183
|
}
|
|
139
184
|
|
|
140
185
|
// Second pass — started + terminal timestamps from every matched UUID.
|
|
@@ -170,7 +215,18 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
|
|
|
170
215
|
}
|
|
171
216
|
}
|
|
172
217
|
|
|
173
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
uuids,
|
|
220
|
+
mode,
|
|
221
|
+
semanticFromLock,
|
|
222
|
+
semanticFromEvents,
|
|
223
|
+
startedAt,
|
|
224
|
+
branch,
|
|
225
|
+
project,
|
|
226
|
+
lastTerminalMs,
|
|
227
|
+
earliestMs,
|
|
228
|
+
lastEventMs,
|
|
229
|
+
};
|
|
174
230
|
}
|
|
175
231
|
|
|
176
232
|
/**
|
|
@@ -213,12 +269,20 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
|
|
|
213
269
|
}
|
|
214
270
|
|
|
215
271
|
/**
|
|
216
|
-
* Build
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
272
|
+
* Build a backfilled stub record. Required fields with no events source are
|
|
273
|
+
* defaulted to empty/zero and enumerated in `_backfill_incomplete_fields`;
|
|
274
|
+
* the mode → session_type coercion sets `_session_type_inferred`.
|
|
275
|
+
*
|
|
276
|
+
* `status` / `backfillSource` are parameterized (#429 — the STATE.md
|
|
277
|
+
* `status: completed` backfill below reuses this exact synthesis, just with
|
|
278
|
+
* a different terminal status and provenance tag). Defaults reproduce the
|
|
279
|
+
* original `backfillAbandonedSession` behaviour exactly for existing callers.
|
|
280
|
+
*
|
|
281
|
+
* `supersedes` (#1068 AC3, default `null`) stamps the id of the backfill STUB
|
|
282
|
+
* this record replaces. It is emitted only when non-null, so every existing
|
|
283
|
+
* record shape is byte-identical to before.
|
|
220
284
|
*/
|
|
221
|
-
function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
285
|
+
function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'abandoned', backfillSource = 'events-jsonl', supersedes = null, rawSessionId = null }) {
|
|
222
286
|
const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
|
|
223
287
|
const startedMs = Date.parse(startedIso);
|
|
224
288
|
// completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
|
|
@@ -271,40 +335,120 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
|
271
335
|
agent_summary: { complete: 0, partial: 0, failed: 0, spiral: 0 },
|
|
272
336
|
total_agents: 0,
|
|
273
337
|
total_files_changed: 0,
|
|
274
|
-
status
|
|
275
|
-
// Issue #773 —
|
|
276
|
-
// is genuinely UNKNOWN. Emit `null` (not
|
|
277
|
-
// consumers can tell "not measured" apart
|
|
278
|
-
// would resurrect the very carryover=0
|
|
338
|
+
status,
|
|
339
|
+
// Issue #773 — a backfilled stub never ran (or cannot be proven to have
|
|
340
|
+
// run) Phase 1.65, so its carryover is genuinely UNKNOWN. Emit `null` (not
|
|
341
|
+
// 0) so downstream effectiveness consumers can tell "not measured" apart
|
|
342
|
+
// from "measured zero" — 0 here would resurrect the very carryover=0
|
|
343
|
+
// blind spot #773 exists to close.
|
|
279
344
|
effectiveness: { carryover: null },
|
|
280
|
-
_backfill_source:
|
|
345
|
+
_backfill_source: backfillSource,
|
|
281
346
|
_backfill_incomplete_fields: incomplete,
|
|
282
347
|
};
|
|
283
348
|
if (branchFound) record.branch = gathered.branch;
|
|
284
349
|
if (inferred) record._session_type_inferred = true;
|
|
285
350
|
if (synthetic) record._synthetic_session_id = true;
|
|
286
351
|
if (completedEstimated) record._completed_at_estimated = true;
|
|
352
|
+
// #1068 AC3/AC4 — forensic supersede marker. sessions.jsonl is append-only,
|
|
353
|
+
// so the stub itself cannot be stamped `superseded_by`; the FORWARD pointer
|
|
354
|
+
// lives on the newer record instead, and the stub survives verbatim (AC4:
|
|
355
|
+
// "historische Stub-Provenance bleibt erhalten"). Readers resolve one
|
|
356
|
+
// canonical state per id by taking the NEWEST record for that id.
|
|
357
|
+
if (typeof supersedes === 'string' && supersedes.length > 0) record.supersedes = supersedes;
|
|
358
|
+
// #1167 — the harness UUID this record was reconstructed from, when known.
|
|
359
|
+
// Additive and optional (the schema validates unknown keys pass-through, see
|
|
360
|
+
// session-schema/validator.mjs `_validateOptionalFields`): it is the ONLY key
|
|
361
|
+
// that lets a reader join a semantic record back to its raw uuid. Measured
|
|
362
|
+
// 2026-09-02 @ c3ab480: 0 of 286 existing records carry it, which is exactly
|
|
363
|
+
// why the two backfill writers could not see each other's work.
|
|
364
|
+
if (typeof rawSessionId === 'string' && rawSessionId.length > 0) record.raw_session_id = rawSessionId;
|
|
287
365
|
return record;
|
|
288
366
|
}
|
|
289
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Terminal statuses that mark a ledger record as a BACKFILLED STUB rather than
|
|
370
|
+
* an authoritative close (#1068 AC3).
|
|
371
|
+
*
|
|
372
|
+
* `abandoned` is the only member today: it is what `backfillAbandonedSession`
|
|
373
|
+
* writes when a session never reached `/close`, i.e. a reconstruction, never a
|
|
374
|
+
* self-reported outcome. `completed` is deliberately NOT a member — including
|
|
375
|
+
* the `state-md-completed` backfill, whose status IS the session's own truth
|
|
376
|
+
* claim. That exclusion is also what makes supersede idempotent: the record
|
|
377
|
+
* appended by a supersede is `completed`, so a second run classifies it as
|
|
378
|
+
* canonical and skips instead of superseding its own predecessor forever.
|
|
379
|
+
*/
|
|
380
|
+
const BACKFILL_STUB_STATUSES = new Set(['abandoned']);
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* True when a sessions.jsonl record is a backfilled STUB — reconstructed
|
|
384
|
+
* provenance (`_backfill_source`) AND a stub status. Both are required: a
|
|
385
|
+
* hand-written `abandoned` record with no backfill provenance is somebody's
|
|
386
|
+
* deliberate statement and is never superseded on our own initiative.
|
|
387
|
+
*
|
|
388
|
+
* @param {unknown} record
|
|
389
|
+
* @returns {boolean}
|
|
390
|
+
*/
|
|
391
|
+
function isBackfillStub(record) {
|
|
392
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
|
|
393
|
+
if (typeof record._backfill_source !== 'string') return false;
|
|
394
|
+
return BACKFILL_STUB_STATUSES.has(record.status);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Classify what sessions.jsonl already holds for this identity (#1068 AC3/AC4).
|
|
399
|
+
*
|
|
400
|
+
* Reads the (small) sessions.jsonl exactly once and returns one of:
|
|
401
|
+
* { kind: 'absent' } — nothing recorded yet
|
|
402
|
+
* { kind: 'canonical', matchedId } — an authoritative record exists
|
|
403
|
+
* { kind: 'stub', matchedId, stubId } — only a backfilled stub exists
|
|
404
|
+
*
|
|
405
|
+
* "Newest wins" is the reading rule: sessions.jsonl is APPEND-ONLY, so a
|
|
406
|
+
* superseding record can never rewrite the stub in place — it is appended
|
|
407
|
+
* after it, and the LAST record for an id is therefore the current one. This
|
|
408
|
+
* function reads the same way (last match, not first), so a stub that has
|
|
409
|
+
* already been superseded classifies as `canonical` and is never superseded
|
|
410
|
+
* twice.
|
|
411
|
+
*
|
|
412
|
+
* @param {Function} readFileSync
|
|
413
|
+
* @param {string} sessionsPath
|
|
414
|
+
* @param {{recordId: string, sessionId: string|null}} ids
|
|
415
|
+
*/
|
|
416
|
+
function classifyExisting(readFileSync, sessionsPath, { recordId, sessionId }) {
|
|
417
|
+
const sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
|
|
418
|
+
// Both keys count: the semantic record id, and — defensively — a prior record
|
|
419
|
+
// keyed directly by the UUID.
|
|
420
|
+
const uuidKey = isUuid(sessionId) ? sessionId : null;
|
|
421
|
+
const byId = (id) =>
|
|
422
|
+
id === null ? [] : sessionRecords.filter((r) => r && r.session_id === id);
|
|
423
|
+
// Key preference is UNCHANGED from the pre-#1068 dedupe: the semantic
|
|
424
|
+
// recordId wins whenever any record carries it, and the UUID key is only the
|
|
425
|
+
// defensive fallback.
|
|
426
|
+
const semanticMatches = byId(recordId);
|
|
427
|
+
const matches = semanticMatches.length > 0 ? semanticMatches : byId(uuidKey);
|
|
428
|
+
if (matches.length === 0) return { kind: 'absent' };
|
|
429
|
+
|
|
430
|
+
const newest = matches[matches.length - 1];
|
|
431
|
+
if (isBackfillStub(newest)) {
|
|
432
|
+
return { kind: 'stub', matchedId: newest.session_id, stubId: newest.session_id };
|
|
433
|
+
}
|
|
434
|
+
return { kind: 'canonical', matchedId: newest.session_id };
|
|
435
|
+
}
|
|
436
|
+
|
|
290
437
|
/**
|
|
291
438
|
* Dedupe against sessions.jsonl. Returns a `skipped-already-recorded` result
|
|
292
439
|
* when `recordId` (or a UUID `sessionId` written directly as a key) is already
|
|
293
|
-
* present, else `null`.
|
|
440
|
+
* present, else `null`.
|
|
441
|
+
*
|
|
442
|
+
* HARD dedupe by design — it is the guard for `backfillAbandonedSession`, whose
|
|
443
|
+
* output is itself a stub: replacing one stub with another buys nothing and
|
|
444
|
+
* would re-append on every SessionEnd. The supersede path (#1068 AC3) belongs
|
|
445
|
+
* to the AUTHORITATIVE writer only; see `classifyExisting` + its use in
|
|
446
|
+
* `backfillCompletedFromStateMd`.
|
|
294
447
|
*/
|
|
295
448
|
function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId }) {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
);
|
|
300
|
-
if (existingIds.has(recordId)) {
|
|
301
|
-
return { action: 'skipped-already-recorded', sessionId: recordId };
|
|
302
|
-
}
|
|
303
|
-
// Defensive: a prior record keyed directly by the UUID also counts.
|
|
304
|
-
if (isUuid(sessionId) && existingIds.has(sessionId)) {
|
|
305
|
-
return { action: 'skipped-already-recorded', sessionId };
|
|
306
|
-
}
|
|
307
|
-
return null;
|
|
449
|
+
const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId });
|
|
450
|
+
if (existing.kind === 'absent') return null;
|
|
451
|
+
return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
|
|
308
452
|
}
|
|
309
453
|
|
|
310
454
|
// ---------------------------------------------------------------------------
|
|
@@ -424,8 +568,11 @@ export async function backfillAbandonedSession({
|
|
|
424
568
|
|
|
425
569
|
// -- Resolve a deferred id from the lock bridge or a synthetic mint ------
|
|
426
570
|
if (recordId === null) {
|
|
427
|
-
|
|
428
|
-
|
|
571
|
+
// Prefer the lock bridge (it also carries `mode`), then the #1167
|
|
572
|
+
// session.ended bridge. Only when NEITHER attests a semantic id do we
|
|
573
|
+
// mint a synthetic one — that fallback was the duplicate-stub source.
|
|
574
|
+
if (gathered.semanticFromLock || gathered.semanticFromEvents) {
|
|
575
|
+
recordId = gathered.semanticFromLock || gathered.semanticFromEvents;
|
|
429
576
|
} else {
|
|
430
577
|
// No semantic bridge — mint a synthetic id. Both components are STABLE
|
|
431
578
|
// across re-runs so dedupe/marker suppress a double write (idempotency
|
|
@@ -508,7 +655,13 @@ export async function backfillAbandonedSession({
|
|
|
508
655
|
}
|
|
509
656
|
|
|
510
657
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ---
|
|
511
|
-
const record = synthesizeRecord({
|
|
658
|
+
const record = synthesizeRecord({
|
|
659
|
+
recordId,
|
|
660
|
+
synthetic,
|
|
661
|
+
gathered,
|
|
662
|
+
nowMs,
|
|
663
|
+
rawSessionId: isUuid(sessionId) ? sessionId : null,
|
|
664
|
+
});
|
|
512
665
|
let validated;
|
|
513
666
|
try {
|
|
514
667
|
validated = validateSession(record);
|
|
@@ -583,6 +736,243 @@ export async function backfillAbandonedSession({
|
|
|
583
736
|
}
|
|
584
737
|
}
|
|
585
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Backfill a `status: 'completed'` session record from STATE.md when STATE.md
|
|
741
|
+
* itself already carries `status: completed` but sessions.jsonl has no
|
|
742
|
+
* matching record (#429).
|
|
743
|
+
*
|
|
744
|
+
* ── THE GAP THIS CLOSES ──────────────────────────────────────────────────
|
|
745
|
+
* `commands/close.md`'s Pre-Check treats `STATE.md status: completed` as
|
|
746
|
+
* PROOF that the session-end skill's Phase 3.7 writer already ran, and
|
|
747
|
+
* stops before invoking it — including when `status: completed` was set by
|
|
748
|
+
* hand (or by any path that never reached Phase 3.7). The session then has
|
|
749
|
+
* no record in sessions.jsonl, permanently: nothing else ever re-drives the
|
|
750
|
+
* write, because the Pre-Check keeps refusing to invoke session-end for as
|
|
751
|
+
* long as STATE.md says `completed`. This function is the mechanical
|
|
752
|
+
* self-heal — every SessionEnd hook firing checks the invariant
|
|
753
|
+
* "STATE.md completed ⇒ a record exists" and repairs it once, regardless of
|
|
754
|
+
* which session's hook happens to run next.
|
|
755
|
+
*
|
|
756
|
+
* ── WHY `status: 'completed'`, NOT `'abandoned'` ─────────────────────────
|
|
757
|
+
* `backfillAbandonedSession` (above) marks its stub `abandoned` because a
|
|
758
|
+
* session that never reached `/close` is, by definition, unfinished. This
|
|
759
|
+
* case is the opposite: STATE.md's own `status: completed` is a truth claim
|
|
760
|
+
* the session made about itself — only the LEDGER write failed to run. The
|
|
761
|
+
* record is tagged `_backfill_source: 'state-md-completed'` (never
|
|
762
|
+
* `'events-jsonl'`) so a later reader can tell the two backfill classes
|
|
763
|
+
* apart at a glance.
|
|
764
|
+
*
|
|
765
|
+
* ── DATA SOURCE ──────────────────────────────────────────────────────────
|
|
766
|
+
* Required numeric counters (`total_waves`, `total_agents`,
|
|
767
|
+
* `total_files_changed`, `agent_summary`) are derived ONLY from
|
|
768
|
+
* events.jsonl via the same `collectSessionEvents` + `synthesizeRecord`
|
|
769
|
+
* machinery `backfillAbandonedSession` uses — never from STATE.md's own
|
|
770
|
+
* body sections (Wave History, etc.), which this module deliberately never
|
|
771
|
+
* parses. Schema `REQUIRED_FIELDS` forbids `null` on these (non-negative
|
|
772
|
+
* number, `session-schema/validator.mjs`), so "otherwise null" is realized
|
|
773
|
+
* as "otherwise 0, flagged in `_backfill_incomplete_fields`" — the same
|
|
774
|
+
* contract the abandoned path already carries and the same reason it exists.
|
|
775
|
+
*
|
|
776
|
+
* CONSTRAINT specific to this path: STATE.md's `session` field is already
|
|
777
|
+
* the SEMANTIC id, never the raw harness UUID — unlike
|
|
778
|
+
* `backfillAbandonedSession` (which usually receives the UUID directly from
|
|
779
|
+
* SessionEnd stdin), this function has no UUID to seed `collectSessionEvents`'s
|
|
780
|
+
* `uuids` set with. Events therefore only surface here when a
|
|
781
|
+
* `lock.acquired` breadcrumb bridges the UUID to this exact semantic id
|
|
782
|
+
* (`ev.semantic_session_id === recordId`) — the SAME bridge condition
|
|
783
|
+
* `backfillAbandonedSession`'s synthetic-id fallback exists to handle when
|
|
784
|
+
* ABSENT. Without that bridge, `gathered` stays empty and the record still
|
|
785
|
+
* validates (started_at/completed_at both fall back to `now`, flagged
|
|
786
|
+
* incomplete) — degraded but never blocked.
|
|
787
|
+
*
|
|
788
|
+
* Never throws. Returns one of:
|
|
789
|
+
* { action: 'backfilled', sessionId, record } — written to disk
|
|
790
|
+
* { action: 'superseded', sessionId, record, supersedes } — written, replacing a stub (#1068 AC3)
|
|
791
|
+
* { action: 'would-backfill', sessionId, record } — dryRun only
|
|
792
|
+
* { action: 'would-supersede', sessionId, record, supersedes } — dryRun only
|
|
793
|
+
* { action: 'skipped-no-state-md' } — no STATE.md at any candidate path
|
|
794
|
+
* { action: 'skipped-unparseable-state-md' } — frontmatter did not parse
|
|
795
|
+
* { action: 'skipped-not-completed', status } — STATE.md status isn't 'completed'
|
|
796
|
+
* { action: 'skipped-no-session-id' } — completed but no `session:` field
|
|
797
|
+
* { action: 'skipped-already-recorded', sessionId } — sessions.jsonl already has it
|
|
798
|
+
* { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
|
|
799
|
+
* { action: 'error', error, sessionId? } — any failure, swallowed
|
|
800
|
+
*
|
|
801
|
+
* @param {object} args
|
|
802
|
+
* @param {string} args.repoRoot absolute project root
|
|
803
|
+
* @param {number|string} [args.now] ms-since-epoch (test seam) or ISO string
|
|
804
|
+
* @param {boolean} [args.dryRun=false] compute + validate only, no marker/write
|
|
805
|
+
* @param {object} [args.deps] DI overrides (fs, appendJsonl, resolveStateMdPath, …)
|
|
806
|
+
* @returns {Promise<object>}
|
|
807
|
+
*/
|
|
808
|
+
export async function backfillCompletedFromStateMd({
|
|
809
|
+
repoRoot,
|
|
810
|
+
now = Date.now(),
|
|
811
|
+
dryRun = false,
|
|
812
|
+
deps = {},
|
|
813
|
+
} = {}) {
|
|
814
|
+
const {
|
|
815
|
+
readFileSync = fs.readFileSync,
|
|
816
|
+
appendJsonl = defaultAppendJsonl,
|
|
817
|
+
openSync = fs.openSync,
|
|
818
|
+
closeSync = fs.closeSync,
|
|
819
|
+
validateSession = defaultValidateSession,
|
|
820
|
+
serializeSessionLineChecked = defaultSerialize,
|
|
821
|
+
resolveStateMdPath = defaultResolveStateMdPath,
|
|
822
|
+
parseStateMd = defaultParseStateMd,
|
|
823
|
+
log = null,
|
|
824
|
+
} = deps;
|
|
825
|
+
|
|
826
|
+
const nowMs = resolveNowMs(now);
|
|
827
|
+
const result = await run();
|
|
828
|
+
logBreadcrumb(result);
|
|
829
|
+
return result;
|
|
830
|
+
|
|
831
|
+
async function run() {
|
|
832
|
+
try {
|
|
833
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
834
|
+
return { action: 'error', error: 'repoRoot must be a non-empty string' };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// -- Read + parse STATE.md ------------------------------------------------
|
|
838
|
+
const stateMdPath = resolveStateMdPath(repoRoot);
|
|
839
|
+
let raw;
|
|
840
|
+
try {
|
|
841
|
+
raw = readFileSync(stateMdPath, 'utf8');
|
|
842
|
+
} catch {
|
|
843
|
+
return { action: 'skipped-no-state-md' };
|
|
844
|
+
}
|
|
845
|
+
const parsed = parseStateMd(raw);
|
|
846
|
+
if (parsed === null) {
|
|
847
|
+
return { action: 'skipped-unparseable-state-md' };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const stateStatus = parsed.frontmatter?.status;
|
|
851
|
+
if (stateStatus !== 'completed') {
|
|
852
|
+
return { action: 'skipped-not-completed', status: stateStatus ?? null };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const recordId = parsed.frontmatter?.session;
|
|
856
|
+
if (typeof recordId !== 'string' || recordId.length === 0) {
|
|
857
|
+
return { action: 'skipped-no-session-id' };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// -- Dedupe, or SUPERSEDE a backfill stub (#1068 AC3) ---------------------
|
|
861
|
+
// This is the authoritative writer of the pair: STATE.md's own
|
|
862
|
+
// `status: completed` is the session's truth claim about itself, and it
|
|
863
|
+
// arrives with an identity-complete key (the semantic id). When the only
|
|
864
|
+
// thing on file for that identity is a reconstructed `abandoned` stub,
|
|
865
|
+
// the stub is a measurement this record refutes — so we append the fuller
|
|
866
|
+
// record (carrying `supersedes: <stub id>`) instead of skipping. An
|
|
867
|
+
// authoritative record already on file still short-circuits exactly as
|
|
868
|
+
// before.
|
|
869
|
+
const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
|
|
870
|
+
const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId: recordId });
|
|
871
|
+
if (existing.kind === 'canonical') {
|
|
872
|
+
return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
|
|
873
|
+
}
|
|
874
|
+
const supersedes = existing.kind === 'stub' ? existing.stubId : null;
|
|
875
|
+
|
|
876
|
+
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
877
|
+
const eventsPath = path.join(repoRoot, ...EVENTS_REL);
|
|
878
|
+
const events = readJsonlSafe(readFileSync, eventsPath);
|
|
879
|
+
const gathered = collectSessionEvents(events, { sessionId: null, semanticSessionId: recordId });
|
|
880
|
+
|
|
881
|
+
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
882
|
+
const record = synthesizeRecord({
|
|
883
|
+
recordId,
|
|
884
|
+
synthetic: false,
|
|
885
|
+
gathered,
|
|
886
|
+
nowMs,
|
|
887
|
+
status: 'completed',
|
|
888
|
+
backfillSource: 'state-md-completed',
|
|
889
|
+
supersedes,
|
|
890
|
+
// #1167 — the abandoned path stamps this; so must the authoritative
|
|
891
|
+
// one, or the join key exists on exactly the weaker half of the pair.
|
|
892
|
+
// `gathered.uuids` is bridged from lock.acquired / session.ended, so it
|
|
893
|
+
// normally holds EXACTLY the one uuid this semantic id ran under. Two
|
|
894
|
+
// (or zero) means the bridge is ambiguous — omit rather than guess, the
|
|
895
|
+
// same fail-quiet posture as `isUuid(sessionId) ? sessionId : null`.
|
|
896
|
+
rawSessionId: gathered.uuids?.size === 1 ? [...gathered.uuids][0] : null,
|
|
897
|
+
});
|
|
898
|
+
let validated;
|
|
899
|
+
try {
|
|
900
|
+
validated = validateSession(record);
|
|
901
|
+
serializeSessionLineChecked(record);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
return { action: 'error', error: `validation: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (dryRun) {
|
|
907
|
+
return {
|
|
908
|
+
action: supersedes ? 'would-supersede' : 'would-backfill',
|
|
909
|
+
sessionId: recordId,
|
|
910
|
+
record: validated,
|
|
911
|
+
...(supersedes ? { supersedes } : {}),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// -- TOCTOU marker — atomic create-or-fail, own namespace so a concurrent
|
|
916
|
+
// abandoned-path claim for the same id can never collide with this one. --
|
|
917
|
+
const markerPath = path.join(repoRoot, '.orchestrator', 'metrics', markerName(`completed-${recordId}`));
|
|
918
|
+
try {
|
|
919
|
+
const fd = openSync(markerPath, 'wx');
|
|
920
|
+
closeSync(fd);
|
|
921
|
+
} catch (err) {
|
|
922
|
+
if (err && err.code === 'EEXIST') {
|
|
923
|
+
return { action: 'skipped-marker-exists', sessionId: recordId };
|
|
924
|
+
}
|
|
925
|
+
return { action: 'error', error: `marker: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// -- Write via the shared append path -------------------------------------
|
|
929
|
+
try {
|
|
930
|
+
await appendJsonl(sessionsPath, validated);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
return { action: 'error', error: `append: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
action: supersedes ? 'superseded' : 'backfilled',
|
|
936
|
+
sessionId: recordId,
|
|
937
|
+
record: validated,
|
|
938
|
+
...(supersedes ? { supersedes } : {}),
|
|
939
|
+
};
|
|
940
|
+
} catch (err) {
|
|
941
|
+
// Absolute backstop — the hook must never see an exception from here.
|
|
942
|
+
return { action: 'error', error: err?.message ?? String(err) };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Best-effort JSONL breadcrumb (project-local; never cascades). Shares the
|
|
947
|
+
* same log file as backfillAbandonedSession — the two are distinguishable
|
|
948
|
+
* by `_backfill_source` on the eventual sessions.jsonl record, and by the
|
|
949
|
+
* distinct action vocabulary above (`skipped-not-completed`,
|
|
950
|
+
* `skipped-no-state-md`, …) in the breadcrumb itself. */
|
|
951
|
+
function logBreadcrumb(res) {
|
|
952
|
+
try {
|
|
953
|
+
if (typeof log === 'function') {
|
|
954
|
+
log(res);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
958
|
+
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
959
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
960
|
+
fs.appendFileSync(
|
|
961
|
+
logPath,
|
|
962
|
+
JSON.stringify({
|
|
963
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
964
|
+
action: res.action,
|
|
965
|
+
session_id: res.sessionId ?? null,
|
|
966
|
+
...(res.error ? { error: res.error } : {}),
|
|
967
|
+
}) + '\n',
|
|
968
|
+
'utf8'
|
|
969
|
+
);
|
|
970
|
+
} catch {
|
|
971
|
+
/* never let logging cascade into the caller */
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
586
976
|
/** Normalise the `now` arg (number ms | ISO string | undefined) → ms. */
|
|
587
977
|
function resolveNowMs(now) {
|
|
588
978
|
if (typeof now === 'number' && Number.isFinite(now)) return now;
|