session-orchestrator 3.22.0 → 3.23.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/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 +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- 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 +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- 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 +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -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 +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- 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/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 +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- 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/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- 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/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/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 +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- 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 +281 -13
- 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 +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- 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 +159 -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-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/sync.mjs +46 -8
- 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 +16 -32
- 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-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- 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-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- 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/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- 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 +11 -3
- 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 +30 -16
- 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 +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- 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. */
|
|
@@ -213,12 +243,20 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
|
|
|
213
243
|
}
|
|
214
244
|
|
|
215
245
|
/**
|
|
216
|
-
* Build
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
246
|
+
* Build a backfilled stub record. Required fields with no events source are
|
|
247
|
+
* defaulted to empty/zero and enumerated in `_backfill_incomplete_fields`;
|
|
248
|
+
* the mode → session_type coercion sets `_session_type_inferred`.
|
|
249
|
+
*
|
|
250
|
+
* `status` / `backfillSource` are parameterized (#429 — the STATE.md
|
|
251
|
+
* `status: completed` backfill below reuses this exact synthesis, just with
|
|
252
|
+
* a different terminal status and provenance tag). Defaults reproduce the
|
|
253
|
+
* original `backfillAbandonedSession` behaviour exactly for existing callers.
|
|
254
|
+
*
|
|
255
|
+
* `supersedes` (#1068 AC3, default `null`) stamps the id of the backfill STUB
|
|
256
|
+
* this record replaces. It is emitted only when non-null, so every existing
|
|
257
|
+
* record shape is byte-identical to before.
|
|
220
258
|
*/
|
|
221
|
-
function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
259
|
+
function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'abandoned', backfillSource = 'events-jsonl', supersedes = null }) {
|
|
222
260
|
const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
|
|
223
261
|
const startedMs = Date.parse(startedIso);
|
|
224
262
|
// completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
|
|
@@ -271,40 +309,113 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
|
|
|
271
309
|
agent_summary: { complete: 0, partial: 0, failed: 0, spiral: 0 },
|
|
272
310
|
total_agents: 0,
|
|
273
311
|
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
|
|
312
|
+
status,
|
|
313
|
+
// Issue #773 — a backfilled stub never ran (or cannot be proven to have
|
|
314
|
+
// run) Phase 1.65, so its carryover is genuinely UNKNOWN. Emit `null` (not
|
|
315
|
+
// 0) so downstream effectiveness consumers can tell "not measured" apart
|
|
316
|
+
// from "measured zero" — 0 here would resurrect the very carryover=0
|
|
317
|
+
// blind spot #773 exists to close.
|
|
279
318
|
effectiveness: { carryover: null },
|
|
280
|
-
_backfill_source:
|
|
319
|
+
_backfill_source: backfillSource,
|
|
281
320
|
_backfill_incomplete_fields: incomplete,
|
|
282
321
|
};
|
|
283
322
|
if (branchFound) record.branch = gathered.branch;
|
|
284
323
|
if (inferred) record._session_type_inferred = true;
|
|
285
324
|
if (synthetic) record._synthetic_session_id = true;
|
|
286
325
|
if (completedEstimated) record._completed_at_estimated = true;
|
|
326
|
+
// #1068 AC3/AC4 — forensic supersede marker. sessions.jsonl is append-only,
|
|
327
|
+
// so the stub itself cannot be stamped `superseded_by`; the FORWARD pointer
|
|
328
|
+
// lives on the newer record instead, and the stub survives verbatim (AC4:
|
|
329
|
+
// "historische Stub-Provenance bleibt erhalten"). Readers resolve one
|
|
330
|
+
// canonical state per id by taking the NEWEST record for that id.
|
|
331
|
+
if (typeof supersedes === 'string' && supersedes.length > 0) record.supersedes = supersedes;
|
|
287
332
|
return record;
|
|
288
333
|
}
|
|
289
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Terminal statuses that mark a ledger record as a BACKFILLED STUB rather than
|
|
337
|
+
* an authoritative close (#1068 AC3).
|
|
338
|
+
*
|
|
339
|
+
* `abandoned` is the only member today: it is what `backfillAbandonedSession`
|
|
340
|
+
* writes when a session never reached `/close`, i.e. a reconstruction, never a
|
|
341
|
+
* self-reported outcome. `completed` is deliberately NOT a member — including
|
|
342
|
+
* the `state-md-completed` backfill, whose status IS the session's own truth
|
|
343
|
+
* claim. That exclusion is also what makes supersede idempotent: the record
|
|
344
|
+
* appended by a supersede is `completed`, so a second run classifies it as
|
|
345
|
+
* canonical and skips instead of superseding its own predecessor forever.
|
|
346
|
+
*/
|
|
347
|
+
const BACKFILL_STUB_STATUSES = new Set(['abandoned']);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* True when a sessions.jsonl record is a backfilled STUB — reconstructed
|
|
351
|
+
* provenance (`_backfill_source`) AND a stub status. Both are required: a
|
|
352
|
+
* hand-written `abandoned` record with no backfill provenance is somebody's
|
|
353
|
+
* deliberate statement and is never superseded on our own initiative.
|
|
354
|
+
*
|
|
355
|
+
* @param {unknown} record
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
function isBackfillStub(record) {
|
|
359
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
|
|
360
|
+
if (typeof record._backfill_source !== 'string') return false;
|
|
361
|
+
return BACKFILL_STUB_STATUSES.has(record.status);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Classify what sessions.jsonl already holds for this identity (#1068 AC3/AC4).
|
|
366
|
+
*
|
|
367
|
+
* Reads the (small) sessions.jsonl exactly once and returns one of:
|
|
368
|
+
* { kind: 'absent' } — nothing recorded yet
|
|
369
|
+
* { kind: 'canonical', matchedId } — an authoritative record exists
|
|
370
|
+
* { kind: 'stub', matchedId, stubId } — only a backfilled stub exists
|
|
371
|
+
*
|
|
372
|
+
* "Newest wins" is the reading rule: sessions.jsonl is APPEND-ONLY, so a
|
|
373
|
+
* superseding record can never rewrite the stub in place — it is appended
|
|
374
|
+
* after it, and the LAST record for an id is therefore the current one. This
|
|
375
|
+
* function reads the same way (last match, not first), so a stub that has
|
|
376
|
+
* already been superseded classifies as `canonical` and is never superseded
|
|
377
|
+
* twice.
|
|
378
|
+
*
|
|
379
|
+
* @param {Function} readFileSync
|
|
380
|
+
* @param {string} sessionsPath
|
|
381
|
+
* @param {{recordId: string, sessionId: string|null}} ids
|
|
382
|
+
*/
|
|
383
|
+
function classifyExisting(readFileSync, sessionsPath, { recordId, sessionId }) {
|
|
384
|
+
const sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
|
|
385
|
+
// Both keys count: the semantic record id, and — defensively — a prior record
|
|
386
|
+
// keyed directly by the UUID.
|
|
387
|
+
const uuidKey = isUuid(sessionId) ? sessionId : null;
|
|
388
|
+
const byId = (id) =>
|
|
389
|
+
id === null ? [] : sessionRecords.filter((r) => r && r.session_id === id);
|
|
390
|
+
// Key preference is UNCHANGED from the pre-#1068 dedupe: the semantic
|
|
391
|
+
// recordId wins whenever any record carries it, and the UUID key is only the
|
|
392
|
+
// defensive fallback.
|
|
393
|
+
const semanticMatches = byId(recordId);
|
|
394
|
+
const matches = semanticMatches.length > 0 ? semanticMatches : byId(uuidKey);
|
|
395
|
+
if (matches.length === 0) return { kind: 'absent' };
|
|
396
|
+
|
|
397
|
+
const newest = matches[matches.length - 1];
|
|
398
|
+
if (isBackfillStub(newest)) {
|
|
399
|
+
return { kind: 'stub', matchedId: newest.session_id, stubId: newest.session_id };
|
|
400
|
+
}
|
|
401
|
+
return { kind: 'canonical', matchedId: newest.session_id };
|
|
402
|
+
}
|
|
403
|
+
|
|
290
404
|
/**
|
|
291
405
|
* Dedupe against sessions.jsonl. Returns a `skipped-already-recorded` result
|
|
292
406
|
* when `recordId` (or a UUID `sessionId` written directly as a key) is already
|
|
293
|
-
* present, else `null`.
|
|
407
|
+
* present, else `null`.
|
|
408
|
+
*
|
|
409
|
+
* HARD dedupe by design — it is the guard for `backfillAbandonedSession`, whose
|
|
410
|
+
* output is itself a stub: replacing one stub with another buys nothing and
|
|
411
|
+
* would re-append on every SessionEnd. The supersede path (#1068 AC3) belongs
|
|
412
|
+
* to the AUTHORITATIVE writer only; see `classifyExisting` + its use in
|
|
413
|
+
* `backfillCompletedFromStateMd`.
|
|
294
414
|
*/
|
|
295
415
|
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;
|
|
416
|
+
const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId });
|
|
417
|
+
if (existing.kind === 'absent') return null;
|
|
418
|
+
return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
|
|
308
419
|
}
|
|
309
420
|
|
|
310
421
|
// ---------------------------------------------------------------------------
|
|
@@ -583,6 +694,236 @@ export async function backfillAbandonedSession({
|
|
|
583
694
|
}
|
|
584
695
|
}
|
|
585
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Backfill a `status: 'completed'` session record from STATE.md when STATE.md
|
|
699
|
+
* itself already carries `status: completed` but sessions.jsonl has no
|
|
700
|
+
* matching record (#429).
|
|
701
|
+
*
|
|
702
|
+
* ── THE GAP THIS CLOSES ──────────────────────────────────────────────────
|
|
703
|
+
* `commands/close.md`'s Pre-Check treats `STATE.md status: completed` as
|
|
704
|
+
* PROOF that the session-end skill's Phase 3.7 writer already ran, and
|
|
705
|
+
* stops before invoking it — including when `status: completed` was set by
|
|
706
|
+
* hand (or by any path that never reached Phase 3.7). The session then has
|
|
707
|
+
* no record in sessions.jsonl, permanently: nothing else ever re-drives the
|
|
708
|
+
* write, because the Pre-Check keeps refusing to invoke session-end for as
|
|
709
|
+
* long as STATE.md says `completed`. This function is the mechanical
|
|
710
|
+
* self-heal — every SessionEnd hook firing checks the invariant
|
|
711
|
+
* "STATE.md completed ⇒ a record exists" and repairs it once, regardless of
|
|
712
|
+
* which session's hook happens to run next.
|
|
713
|
+
*
|
|
714
|
+
* ── WHY `status: 'completed'`, NOT `'abandoned'` ─────────────────────────
|
|
715
|
+
* `backfillAbandonedSession` (above) marks its stub `abandoned` because a
|
|
716
|
+
* session that never reached `/close` is, by definition, unfinished. This
|
|
717
|
+
* case is the opposite: STATE.md's own `status: completed` is a truth claim
|
|
718
|
+
* the session made about itself — only the LEDGER write failed to run. The
|
|
719
|
+
* record is tagged `_backfill_source: 'state-md-completed'` (never
|
|
720
|
+
* `'events-jsonl'`) so a later reader can tell the two backfill classes
|
|
721
|
+
* apart at a glance.
|
|
722
|
+
*
|
|
723
|
+
* ── DATA SOURCE ──────────────────────────────────────────────────────────
|
|
724
|
+
* Required numeric counters (`total_waves`, `total_agents`,
|
|
725
|
+
* `total_files_changed`, `agent_summary`) are derived ONLY from
|
|
726
|
+
* events.jsonl via the same `collectSessionEvents` + `synthesizeRecord`
|
|
727
|
+
* machinery `backfillAbandonedSession` uses — never from STATE.md's own
|
|
728
|
+
* body sections (Wave History, etc.), which this module deliberately never
|
|
729
|
+
* parses. Schema `REQUIRED_FIELDS` forbids `null` on these (non-negative
|
|
730
|
+
* number, `session-schema/validator.mjs`), so "otherwise null" is realized
|
|
731
|
+
* as "otherwise 0, flagged in `_backfill_incomplete_fields`" — the same
|
|
732
|
+
* contract the abandoned path already carries and the same reason it exists.
|
|
733
|
+
*
|
|
734
|
+
* CONSTRAINT specific to this path: STATE.md's `session` field is already
|
|
735
|
+
* the SEMANTIC id, never the raw harness UUID — unlike
|
|
736
|
+
* `backfillAbandonedSession` (which usually receives the UUID directly from
|
|
737
|
+
* SessionEnd stdin), this function has no UUID to seed `collectSessionEvents`'s
|
|
738
|
+
* `uuids` set with. Events therefore only surface here when a
|
|
739
|
+
* `lock.acquired` breadcrumb bridges the UUID to this exact semantic id
|
|
740
|
+
* (`ev.semantic_session_id === recordId`) — the SAME bridge condition
|
|
741
|
+
* `backfillAbandonedSession`'s synthetic-id fallback exists to handle when
|
|
742
|
+
* ABSENT. Without that bridge, `gathered` stays empty and the record still
|
|
743
|
+
* validates (started_at/completed_at both fall back to `now`, flagged
|
|
744
|
+
* incomplete) — degraded but never blocked.
|
|
745
|
+
*
|
|
746
|
+
* Never throws. Returns one of:
|
|
747
|
+
* { action: 'backfilled', sessionId, record } — written to disk
|
|
748
|
+
* { action: 'superseded', sessionId, record, supersedes } — written, replacing a stub (#1068 AC3)
|
|
749
|
+
* { action: 'would-backfill', sessionId, record } — dryRun only
|
|
750
|
+
* { action: 'would-supersede', sessionId, record, supersedes } — dryRun only
|
|
751
|
+
* { action: 'skipped-no-state-md' } — no STATE.md at any candidate path
|
|
752
|
+
* { action: 'skipped-unparseable-state-md' } — frontmatter did not parse
|
|
753
|
+
* { action: 'skipped-not-completed', status } — STATE.md status isn't 'completed'
|
|
754
|
+
* { action: 'skipped-no-session-id' } — completed but no `session:` field
|
|
755
|
+
* { action: 'skipped-already-recorded', sessionId } — sessions.jsonl already has it
|
|
756
|
+
* { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
|
|
757
|
+
* { action: 'error', error, sessionId? } — any failure, swallowed
|
|
758
|
+
*
|
|
759
|
+
* @param {object} args
|
|
760
|
+
* @param {string} args.repoRoot absolute project root
|
|
761
|
+
* @param {number|string} [args.now] ms-since-epoch (test seam) or ISO string
|
|
762
|
+
* @param {boolean} [args.dryRun=false] compute + validate only, no marker/write
|
|
763
|
+
* @param {object} [args.deps] DI overrides (fs, appendJsonl, resolveStateMdPath, …)
|
|
764
|
+
* @returns {Promise<object>}
|
|
765
|
+
*/
|
|
766
|
+
export async function backfillCompletedFromStateMd({
|
|
767
|
+
repoRoot,
|
|
768
|
+
now = Date.now(),
|
|
769
|
+
dryRun = false,
|
|
770
|
+
deps = {},
|
|
771
|
+
} = {}) {
|
|
772
|
+
const {
|
|
773
|
+
readFileSync = fs.readFileSync,
|
|
774
|
+
appendJsonl = defaultAppendJsonl,
|
|
775
|
+
openSync = fs.openSync,
|
|
776
|
+
closeSync = fs.closeSync,
|
|
777
|
+
validateSession = defaultValidateSession,
|
|
778
|
+
serializeSessionLineChecked = defaultSerialize,
|
|
779
|
+
resolveStateMdPath = defaultResolveStateMdPath,
|
|
780
|
+
parseStateMd = defaultParseStateMd,
|
|
781
|
+
log = null,
|
|
782
|
+
} = deps;
|
|
783
|
+
|
|
784
|
+
const nowMs = resolveNowMs(now);
|
|
785
|
+
const result = await run();
|
|
786
|
+
logBreadcrumb(result);
|
|
787
|
+
return result;
|
|
788
|
+
|
|
789
|
+
async function run() {
|
|
790
|
+
try {
|
|
791
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
792
|
+
return { action: 'error', error: 'repoRoot must be a non-empty string' };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// -- Read + parse STATE.md ------------------------------------------------
|
|
796
|
+
const stateMdPath = resolveStateMdPath(repoRoot);
|
|
797
|
+
let raw;
|
|
798
|
+
try {
|
|
799
|
+
raw = readFileSync(stateMdPath, 'utf8');
|
|
800
|
+
} catch {
|
|
801
|
+
return { action: 'skipped-no-state-md' };
|
|
802
|
+
}
|
|
803
|
+
const parsed = parseStateMd(raw);
|
|
804
|
+
if (parsed === null) {
|
|
805
|
+
return { action: 'skipped-unparseable-state-md' };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const stateStatus = parsed.frontmatter?.status;
|
|
809
|
+
if (stateStatus !== 'completed') {
|
|
810
|
+
return { action: 'skipped-not-completed', status: stateStatus ?? null };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const recordId = parsed.frontmatter?.session;
|
|
814
|
+
if (typeof recordId !== 'string' || recordId.length === 0) {
|
|
815
|
+
return { action: 'skipped-no-session-id' };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// -- Dedupe, or SUPERSEDE a backfill stub (#1068 AC3) ---------------------
|
|
819
|
+
// This is the authoritative writer of the pair: STATE.md's own
|
|
820
|
+
// `status: completed` is the session's truth claim about itself, and it
|
|
821
|
+
// arrives with an identity-complete key (the semantic id). When the only
|
|
822
|
+
// thing on file for that identity is a reconstructed `abandoned` stub,
|
|
823
|
+
// the stub is a measurement this record refutes — so we append the fuller
|
|
824
|
+
// record (carrying `supersedes: <stub id>`) instead of skipping. An
|
|
825
|
+
// authoritative record already on file still short-circuits exactly as
|
|
826
|
+
// before.
|
|
827
|
+
const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
|
|
828
|
+
const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId: recordId });
|
|
829
|
+
if (existing.kind === 'canonical') {
|
|
830
|
+
return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
|
|
831
|
+
}
|
|
832
|
+
const supersedes = existing.kind === 'stub' ? existing.stubId : null;
|
|
833
|
+
|
|
834
|
+
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
835
|
+
const eventsPath = path.join(repoRoot, ...EVENTS_REL);
|
|
836
|
+
const events = readJsonlSafe(readFileSync, eventsPath);
|
|
837
|
+
const gathered = collectSessionEvents(events, { sessionId: null, semanticSessionId: recordId });
|
|
838
|
+
|
|
839
|
+
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
840
|
+
const record = synthesizeRecord({
|
|
841
|
+
recordId,
|
|
842
|
+
synthetic: false,
|
|
843
|
+
gathered,
|
|
844
|
+
nowMs,
|
|
845
|
+
status: 'completed',
|
|
846
|
+
backfillSource: 'state-md-completed',
|
|
847
|
+
supersedes,
|
|
848
|
+
});
|
|
849
|
+
let validated;
|
|
850
|
+
try {
|
|
851
|
+
validated = validateSession(record);
|
|
852
|
+
serializeSessionLineChecked(record);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
return { action: 'error', error: `validation: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (dryRun) {
|
|
858
|
+
return {
|
|
859
|
+
action: supersedes ? 'would-supersede' : 'would-backfill',
|
|
860
|
+
sessionId: recordId,
|
|
861
|
+
record: validated,
|
|
862
|
+
...(supersedes ? { supersedes } : {}),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// -- TOCTOU marker — atomic create-or-fail, own namespace so a concurrent
|
|
867
|
+
// abandoned-path claim for the same id can never collide with this one. --
|
|
868
|
+
const markerPath = path.join(repoRoot, '.orchestrator', 'metrics', markerName(`completed-${recordId}`));
|
|
869
|
+
try {
|
|
870
|
+
const fd = openSync(markerPath, 'wx');
|
|
871
|
+
closeSync(fd);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
if (err && err.code === 'EEXIST') {
|
|
874
|
+
return { action: 'skipped-marker-exists', sessionId: recordId };
|
|
875
|
+
}
|
|
876
|
+
return { action: 'error', error: `marker: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// -- Write via the shared append path -------------------------------------
|
|
880
|
+
try {
|
|
881
|
+
await appendJsonl(sessionsPath, validated);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
return { action: 'error', error: `append: ${err?.message ?? String(err)}`, sessionId: recordId };
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
action: supersedes ? 'superseded' : 'backfilled',
|
|
887
|
+
sessionId: recordId,
|
|
888
|
+
record: validated,
|
|
889
|
+
...(supersedes ? { supersedes } : {}),
|
|
890
|
+
};
|
|
891
|
+
} catch (err) {
|
|
892
|
+
// Absolute backstop — the hook must never see an exception from here.
|
|
893
|
+
return { action: 'error', error: err?.message ?? String(err) };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Best-effort JSONL breadcrumb (project-local; never cascades). Shares the
|
|
898
|
+
* same log file as backfillAbandonedSession — the two are distinguishable
|
|
899
|
+
* by `_backfill_source` on the eventual sessions.jsonl record, and by the
|
|
900
|
+
* distinct action vocabulary above (`skipped-not-completed`,
|
|
901
|
+
* `skipped-no-state-md`, …) in the breadcrumb itself. */
|
|
902
|
+
function logBreadcrumb(res) {
|
|
903
|
+
try {
|
|
904
|
+
if (typeof log === 'function') {
|
|
905
|
+
log(res);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
909
|
+
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
910
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
911
|
+
fs.appendFileSync(
|
|
912
|
+
logPath,
|
|
913
|
+
JSON.stringify({
|
|
914
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
915
|
+
action: res.action,
|
|
916
|
+
session_id: res.sessionId ?? null,
|
|
917
|
+
...(res.error ? { error: res.error } : {}),
|
|
918
|
+
}) + '\n',
|
|
919
|
+
'utf8'
|
|
920
|
+
);
|
|
921
|
+
} catch {
|
|
922
|
+
/* never let logging cascade into the caller */
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
586
927
|
/** Normalise the `now` arg (number ms | ISO string | undefined) → ms. */
|
|
587
928
|
function resolveNowMs(now) {
|
|
588
929
|
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
@@ -46,6 +46,7 @@ import os from 'node:os';
|
|
|
46
46
|
import { listWorktrees } from './worktree/listing.mjs';
|
|
47
47
|
import { readLock, isLockLive } from './session-lock.mjs';
|
|
48
48
|
import { readRegistry, repoPathHash, isRegistryEntryFresh } from './session-registry.mjs';
|
|
49
|
+
import { stableHostname } from './host-identity.mjs';
|
|
49
50
|
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
// Constants
|
|
@@ -66,7 +67,7 @@ export const DEFAULT_DISCOVERY_TIMEOUT_MS = 2000;
|
|
|
66
67
|
* @param {object} lock Parsed lock body (schema v2).
|
|
67
68
|
* @param {string} worktreePath Absolute worktree path.
|
|
68
69
|
* @param {string} [branch] Branch from worktree object (falsy → '').
|
|
69
|
-
* @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}}
|
|
70
|
+
* @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}}
|
|
70
71
|
*/
|
|
71
72
|
function sessionFromLock(lock, worktreePath, branch = '') {
|
|
72
73
|
return {
|
|
@@ -76,6 +77,12 @@ function sessionFromLock(lock, worktreePath, branch = '') {
|
|
|
76
77
|
startedAt: lock.started_at,
|
|
77
78
|
pid: lock.pid,
|
|
78
79
|
host: lock.host,
|
|
80
|
+
// Additive normalised twin (#1072), same field the registry path emits —
|
|
81
|
+
// without it the two discovery sources returned DIFFERENT shapes, and a
|
|
82
|
+
// consumer comparing hosts silently fell back to the raw `host` for
|
|
83
|
+
// lock-sourced sessions only. Locks written before #1072 carry no
|
|
84
|
+
// `host_id`, so derive it from the raw name rather than emitting undefined.
|
|
85
|
+
host_id: lock.host_id ?? stableHostname(lock.host),
|
|
79
86
|
branch: typeof branch === 'string' ? branch : '',
|
|
80
87
|
};
|
|
81
88
|
}
|
|
@@ -100,6 +107,9 @@ function sessionFromRegistryEntry(entry, repoRoot) {
|
|
|
100
107
|
// local hostname as the default since registry entries are host-scoped
|
|
101
108
|
// by design (see ~/.config/session-orchestrator/sessions/).
|
|
102
109
|
host: os.hostname(),
|
|
110
|
+
// Additive normalised twin (#1072) — `os.hostname()` is not stable on a
|
|
111
|
+
// single machine, so consumers comparing hosts need the normalised form.
|
|
112
|
+
host_id: stableHostname(),
|
|
103
113
|
branch: typeof entry.branch === 'string' ? entry.branch : '',
|
|
104
114
|
};
|
|
105
115
|
}
|
|
@@ -111,7 +121,7 @@ function sessionFromRegistryEntry(entry, repoRoot) {
|
|
|
111
121
|
* Liveness rule: heartbeat freshness via isLockLive (Epic #583, W2-I3).
|
|
112
122
|
*
|
|
113
123
|
* @param {string} repoRoot
|
|
114
|
-
* @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>}
|
|
124
|
+
* @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}>}
|
|
115
125
|
*/
|
|
116
126
|
function readLocalSession(repoRoot) {
|
|
117
127
|
const lock = readLock({ repoRoot });
|
|
@@ -167,7 +177,7 @@ function dedupeBySessionId(sessions) {
|
|
|
167
177
|
* @param {Function} [opts.registryReader] DI hook replacing readRegistry() for tests.
|
|
168
178
|
* @param {number} [opts.freshnessMin=15] Registry-entry freshness threshold in minutes.
|
|
169
179
|
* @param {number} [opts.now] ms-since-epoch (test seam for heartbeat freshness).
|
|
170
|
-
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>>}
|
|
180
|
+
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}>>}
|
|
171
181
|
*/
|
|
172
182
|
export async function discoverActiveSessions(repoRoot, opts = {}) {
|
|
173
183
|
const listWorktreesFn = opts.listWorktreesImpl ?? listWorktrees;
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
* ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' | 'auto-dream-signal' |
|
|
38
38
|
* 'skill-invocations.jsonl' | 'auto-dialectic-signal' | 'reconcile-dry-run' |
|
|
39
39
|
* 'learnings.jsonl' | 'probe-error').
|
|
40
|
+
* @property {string[]} [targets] - 3.6.8 only, RUN decisions only: the effective
|
|
41
|
+
* reconcile write-targets (issue #1099). Additive — absent on every other phase.
|
|
42
|
+
* @property {string|null} [baselineRoot] - 3.6.8 only, RUN decisions only: the
|
|
43
|
+
* resolved projects-baseline root, or null when `baseline` is not in effect.
|
|
40
44
|
*
|
|
41
45
|
* @typedef {Object} TailPlan
|
|
42
46
|
* @property {PhaseDecision[]} plan
|
|
@@ -51,7 +55,7 @@ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
|
51
55
|
import { shouldDispatchAutoDream } from '../auto-dream.mjs';
|
|
52
56
|
import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
|
|
53
57
|
import { readSkillInvocations } from '../skill-invocations-schema.mjs';
|
|
54
|
-
import { runReconcile } from '../reconcile/engine.mjs';
|
|
58
|
+
import { runReconcile, resolveEffectiveTargets } from '../reconcile/engine.mjs';
|
|
55
59
|
import { resolveMemoryDir } from '../memory-paths.mjs';
|
|
56
60
|
|
|
57
61
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +182,9 @@ async function decideAutoDream({ repoRoot, cfg, platform, memoryDir }) {
|
|
|
178
182
|
if (!isClaudePlatform(platform)) {
|
|
179
183
|
return mkSkip(phase, 'non-Claude-Code platform (memory dir unavailable)', 'config-gate');
|
|
180
184
|
}
|
|
181
|
-
|
|
185
|
+
// #1071: same root as the `repoRoot` handed to shouldDispatchAutoDream below —
|
|
186
|
+
// a cwd-derived memory dir made the two halves of this decision disagree.
|
|
187
|
+
const dir = memoryDir ?? resolveMemoryDir(repoRoot);
|
|
182
188
|
const dec = await shouldDispatchAutoDream({
|
|
183
189
|
repoRoot,
|
|
184
190
|
memoryDir: dir,
|
|
@@ -259,6 +265,14 @@ async function decideAutoDialectic({ repoRoot, cfg }) {
|
|
|
259
265
|
* with `dryRun: true` (SKIPs the candidate-sidecar merge — no write) and the
|
|
260
266
|
* operator confidence-floor delivery gate. 0 proposals above floor → skip.
|
|
261
267
|
* runReconcile never throws; an engine `error` is treated fail-open (run).
|
|
268
|
+
*
|
|
269
|
+
* Target resolution (#1099) happens HERE — the layer where config → host-path
|
|
270
|
+
* resolution already lives — and deliberately only on the RUN branch: a WARN
|
|
271
|
+
* about an unusable baseline root in a session that had nothing to propose
|
|
272
|
+
* anyway would fire on almost every session and teach the operator to ignore it
|
|
273
|
+
* (`.claude/rules/host-resources.md` § HR-101). A `baseline` target that cannot
|
|
274
|
+
* be written is dropped BEFORE the coordinator surfaces the approval AUQ, so the
|
|
275
|
+
* operator is never asked to approve a write to a destination that cannot exist.
|
|
262
276
|
*/
|
|
263
277
|
async function decideReconcile({ repoRoot, cfg }) {
|
|
264
278
|
const phase = '3.6.8';
|
|
@@ -292,7 +306,26 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
292
306
|
'reconcile-dry-run',
|
|
293
307
|
);
|
|
294
308
|
}
|
|
295
|
-
|
|
309
|
+
const { targets, baselineRoot } = resolveEffectiveTargets({
|
|
310
|
+
targets: cfg?.reconcile?.targets,
|
|
311
|
+
baselineRoot: cfg?.['plan-baseline-path'],
|
|
312
|
+
});
|
|
313
|
+
if (targets.length === 0) {
|
|
314
|
+
return mkSkip(
|
|
315
|
+
phase,
|
|
316
|
+
`${surfaced.length} proposal(s) above floor but no writable target remains`,
|
|
317
|
+
'config-gate',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
...mkRun(
|
|
322
|
+
phase,
|
|
323
|
+
`${surfaced.length} proposal(s) above confidence floor → targets: ${targets.join(', ')}`,
|
|
324
|
+
'reconcile-dry-run',
|
|
325
|
+
),
|
|
326
|
+
targets,
|
|
327
|
+
baselineRoot,
|
|
328
|
+
};
|
|
296
329
|
} catch (err) {
|
|
297
330
|
return mkProbeError(phase, err);
|
|
298
331
|
}
|
|
@@ -332,7 +365,7 @@ export function buildSkippedReport(plan) {
|
|
|
332
365
|
* @param {object} args.config Parsed Session Config object (from parse-config.mjs).
|
|
333
366
|
* @param {string|null} [args.sessionId] Current session id (for the 3.6.6 judged-set filter).
|
|
334
367
|
* @param {string} [args.platform] Platform id ('claude' | 'codex' | 'cursor' | …).
|
|
335
|
-
* @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir()).
|
|
368
|
+
* @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir(repoRoot)).
|
|
336
369
|
* @returns {Promise<TailPlan>}
|
|
337
370
|
*/
|
|
338
371
|
export async function planTailPhases({ repoRoot, config, sessionId, platform, memoryDir } = {}) {
|