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.
Files changed (268) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. 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
- /** True when `s` is a canonical UUID (v4-shaped). */
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 typeof s === 'string' && UUID_RE.test(s);
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 the abandoned-session stub record. Required fields with no events
217
- * source are defaulted to empty/zero and enumerated in
218
- * `_backfill_incomplete_fields`; the mode → session_type coercion sets
219
- * `_session_type_inferred`.
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: 'abandoned',
275
- // Issue #773 — an abandoned session never ran Phase 1.65, so its carryover
276
- // is genuinely UNKNOWN. Emit `null` (not 0) so downstream effectiveness
277
- // consumers can tell "not measured" apart from "measured zero" — 0 here
278
- // would resurrect the very carryover=0 blind spot #773 exists to close.
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: 'events-jsonl',
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`. Reads the (small) sessions.jsonl exactly once.
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 sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
297
- const existingIds = new Set(
298
- sessionRecords.map((r) => (r && typeof r.session_id === 'string' ? r.session_id : null)).filter(Boolean)
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
- const dir = memoryDir ?? resolveMemoryDir();
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
- return mkRun(phase, `${surfaced.length} proposal(s) above confidence floor`, 'reconcile-dry-run');
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 } = {}) {