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
@@ -2,9 +2,12 @@
2
2
  * worktree-cleanup.mjs — Phase 4a Auto-Promoted Worktree Cleanup helpers (#575 P3.2).
3
3
  *
4
4
  * Public API:
5
- * - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch } | null
5
+ * - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch, source } | null
6
6
  * - isWorktreeClean(wtPath, opts): boolean
7
7
  * (opts.execFileFn — injectable execFileSync seam for tests; #577 HARDEN-001)
8
+ * - PROMOTION_MARKER_RELPATH — repo-relative path of the promotion marker
9
+ * written by `enterWorktree()` (SSOT for the file location; the WRITER
10
+ * imports this constant from here, so writer and reader can never drift).
8
11
  *
9
12
  * Closes #575 — Epic #568 Phase 3.2 (Parallel-Aware Sessions Auto-Promoted Worktree Cleanup)
10
13
  * PRD: "Parallel-aware sessions" (#568; archived in the private Meta-Vault) §3 P3 Gherkin rows 2-3
@@ -21,27 +24,162 @@
21
24
  * kept divergent on purpose — unifying them would break the sync/async boundary.
22
25
  */
23
26
  import path from 'node:path';
27
+ import { readFileSync } from 'node:fs';
24
28
  import { execFileSync } from 'node:child_process';
25
29
  import { parseSessionId } from '../session-id.mjs';
26
30
 
31
+ /**
32
+ * Repo-relative location of the promotion marker `enterWorktree()` drops into
33
+ * every worktree it creates. Deliberately inside `.orchestrator/` (the session
34
+ * state dir) and deliberately WITHOUT any absolute path in its payload — the
35
+ * source checkout is recorded as `repoPathHash()` so the file can be committed
36
+ * or shipped without leaking the operator's filesystem layout.
37
+ *
38
+ * @type {string}
39
+ */
40
+ export const PROMOTION_MARKER_RELPATH = path.join('.orchestrator', 'promoted-from.json');
41
+
42
+ /**
43
+ * Read + shape-validate the promotion marker of a candidate worktree.
44
+ *
45
+ * Never throws: a missing file, a directory, unreadable permissions, invalid
46
+ * JSON, or a payload of the wrong shape all mean "no marker" (→ legacy path).
47
+ *
48
+ * @param {string} repoRoot
49
+ * @returns {{branch: string, source_session_id: string} & Record<string, unknown> | null}
50
+ */
51
+ function readPromotionMarker(repoRoot) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(readFileSync(path.join(repoRoot, PROMOTION_MARKER_RELPATH), 'utf8'));
55
+ } catch {
56
+ return null; // absent / unreadable / corrupt JSON — fall back to legacy detection
57
+ }
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
59
+ if (typeof parsed.branch !== 'string' || parsed.branch.length === 0) return null;
60
+ if (typeof parsed.source_session_id !== 'string' || parsed.source_session_id.length === 0) {
61
+ return null;
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ /**
67
+ * The marker path as git reports it in `status --porcelain` — always
68
+ * forward-slashed, regardless of the host separator.
69
+ * @type {string}
70
+ */
71
+ const PROMOTION_MARKER_GIT_PATH = PROMOTION_MARKER_RELPATH.split(path.sep).join('/');
72
+
73
+ /**
74
+ * True for the ONE porcelain line the promotion marker itself produces:
75
+ * `?? .orchestrator/promoted-from.json`.
76
+ *
77
+ * Why this exists: in THIS repo `.gitignore` already lists
78
+ * `.orchestrator/promoted-from.json` explicitly (`git check-ignore
79
+ * .orchestrator/promoted-from.json` exits 0), so the marker never reaches
80
+ * `git status --porcelain` here at all — this exemption is redundant
81
+ * belt-and-braces for the repo that ships it. It earns its keep in CONSUMER
82
+ * repos: `.orchestrator/` is commonly only PARTLY gitignored there
83
+ * (`.orchestrator/metrics/*.jsonl`, `session.lock`, … but not the bare
84
+ * directory, and not necessarily this file), and on a worktree whose branch
85
+ * predates that ignore line being added, the marker would show up as an
86
+ * untracked file and make every promoted worktree read "dirty" — turning the
87
+ * Phase 4a clean path (auto-remove) into a permanent operator AUQ. Repos that
88
+ * gitignore `.orchestrator/` wholesale never see the line at all. A third shape exists —
89
+ * the directory untracked AND un-ignored, where git collapses everything into
90
+ * one `?? .orchestrator/` line — and is deliberately NOT matched here:
91
+ * discounting a whole directory could hide operator work, and such a worktree
92
+ * is already dirty from the session's own `session.lock` / `STATE.md` writes
93
+ * regardless of this marker.
94
+ *
95
+ * Deliberately narrow: ONLY the untracked (`??`) status of exactly this path is
96
+ * ignored. A modified, staged, renamed or conflicted marker still counts as
97
+ * dirty, and no other file under `.orchestrator/` is affected — this is our own
98
+ * bookkeeping artefact, not operator work (PSA-003: we created it, so it is
99
+ * ours to discount).
100
+ *
101
+ * @param {string} line - One `git status --porcelain` line.
102
+ * @returns {boolean}
103
+ */
104
+ function isUntrackedPromotionMarker(line) {
105
+ if (!line.startsWith('?? ')) return false;
106
+ const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
107
+ return filePath === PROMOTION_MARKER_GIT_PATH;
108
+ }
109
+
110
+ /**
111
+ * Current branch of a worktree, or `null` when it cannot be determined
112
+ * (git error, or a detached HEAD — `git branch --show-current` prints nothing).
113
+ *
114
+ * @param {Function} execFileFn
115
+ * @param {string} repoRoot
116
+ * @returns {string|null}
117
+ */
118
+ function currentBranchOf(execFileFn, repoRoot) {
119
+ try {
120
+ const out = execFileFn('git', ['-C', repoRoot, 'branch', '--show-current'], {
121
+ encoding: 'utf8',
122
+ });
123
+ const branch = String(out ?? '').trim();
124
+ return branch.length > 0 ? branch : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
27
130
  /**
28
131
  * Detect whether the given repoRoot is an auto-promoted sibling worktree
29
132
  * created by `enterWorktree()` during the Phase 0.5 PROMOTION_OFFER path.
30
133
  *
31
- * Auto-promoted layout: <basePath>/<repo-name>-<sessionId>/
134
+ * Two keys, tried in this order:
135
+ *
136
+ * 1. **Marker (primary).** `<repoRoot>/.orchestrator/promoted-from.json`,
137
+ * written by `enterWorktree()` at creation time. Accepted when the file
138
+ * parses, carries `branch` + `source_session_id`, and the worktree's
139
+ * current branch either MATCHES the recorded one or cannot be read at all.
140
+ * This is the only key that survives the #1069 process boundary: since
141
+ * #1069 the session that RUNS in the promoted worktree is a NEW session
142
+ * with its OWN id, and since #1067 the worktree sits on `so/<sourceId>` —
143
+ * so the current session's id appears in neither the directory name nor
144
+ * the branch, and key 2 below can never match. Recording the fact at
145
+ * creation time is what makes it re-derivable later.
146
+ * 2. **Basename (legacy fallback).** `<basePath>/<main-repo-name>-<sessionId>/`
147
+ * against the CURRENT session id — still correct for worktrees created
148
+ * before the marker existed, and for the same-session case.
32
149
  *
33
150
  * Returns:
34
- * { wtPath, sessionId, branch } on match
151
+ * { wtPath, sessionId, branch, source: 'marker'|'basename' } on match
35
152
  * null on non-match (UUID session, non-promoted path, or git error)
36
153
  *
37
154
  * @param {string} repoRoot - Absolute path to the candidate worktree
38
155
  * @param {string} sessionId - Session ID (semantic or UUID)
39
- * @returns {{wtPath: string, sessionId: string, branch: string} | null}
156
+ * @returns {{wtPath: string, sessionId: string, branch: string, source: 'marker'|'basename'} | null}
40
157
  */
41
158
  export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
42
159
  // #577 HARDEN-001: execFileSync + args ARRAY (no shell) is structurally
43
160
  // injection-proof — repoRoot can never be interpreted as shell metacharacters.
44
161
  const execFileFn = opts.execFileFn ?? execFileSync;
162
+
163
+ // --- Key 1: the marker written at creation time (session-id independent) ---
164
+ const marker = readPromotionMarker(repoRoot);
165
+ if (marker) {
166
+ const current = currentBranchOf(execFileFn, repoRoot);
167
+ // `current === null` (git unavailable / detached HEAD) is accepted: the
168
+ // marker is written by exactly one code path, and the destructive step in
169
+ // Phase 4a is gated separately by `isWorktreeClean()`, which fails CLOSED
170
+ // on any git error. So an unverifiable branch can only ever route the
171
+ // operator into the AUQ, never into an automatic removal.
172
+ if (current === null || current === marker.branch) {
173
+ return {
174
+ wtPath: repoRoot,
175
+ sessionId: marker.source_session_id,
176
+ branch: marker.branch,
177
+ source: 'marker',
178
+ };
179
+ }
180
+ }
181
+
182
+ // --- Key 2: legacy basename match against the CURRENT session id ---
45
183
  const parsed = parseSessionId(sessionId);
46
184
  if (!parsed || parsed.format !== 'semantic') return null; // UUID-format sessions are never auto-promoted
47
185
 
@@ -72,7 +210,7 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
72
210
  const isPromotedPath = path.basename(repoRoot) === expectedBasename;
73
211
 
74
212
  if (isPromotedPath) {
75
- return { wtPath: repoRoot, sessionId, branch: parsed.branch };
213
+ return { wtPath: repoRoot, sessionId, branch: parsed.branch, source: 'basename' };
76
214
  }
77
215
  return null;
78
216
  }
@@ -82,7 +220,9 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
82
220
  *
83
221
  * A worktree is clean iff ALL three conditions hold:
84
222
  * 1. No uncommitted changes (`git status --porcelain` is empty)
85
- * 2. No untracked files (implicit in #1 — porcelain includes `??` entries)
223
+ * 2. No untracked files (implicit in #1 — porcelain includes `??` entries),
224
+ * with ONE exception: the untracked promotion marker this module's own
225
+ * writer drops into the worktree (see isUntrackedPromotionMarker)
86
226
  * 3. No unpushed commits (`git status --short --branch` lacks `ahead`)
87
227
  *
88
228
  * On any git error, returns `false` (safer per PSA-003 — conservative default
@@ -98,7 +238,14 @@ export function isWorktreeClean(wtPath, opts = {}) {
98
238
  const status = execFileFn('git', ['-C', wtPath, 'status', '--porcelain'], {
99
239
  encoding: 'utf8',
100
240
  });
101
- if (status.trim().length > 0) return false; // dirty (modified, untracked, or staged)
241
+ const significant = String(status ?? '')
242
+ .split('\n')
243
+ .map((l) => l.trimEnd())
244
+ .filter((l) => l.length > 0)
245
+ // Our own promotion marker is not operator work — see
246
+ // isUntrackedPromotionMarker() for why it must not count as dirty.
247
+ .filter((l) => !isUntrackedPromotionMarker(l));
248
+ if (significant.length > 0) return false; // dirty (modified, untracked, or staged)
102
249
 
103
250
  const branchStatus = execFileFn('git', ['-C', wtPath, 'status', '--short', '--branch'], {
104
251
  encoding: 'utf8',
@@ -6,7 +6,7 @@
6
6
  * - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
7
7
  * - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
8
8
  * - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
9
- * - UUID_V4_RE — regex for UUID-v4 format session IDs
9
+ * - UUID_RE — regex for RFC 9562 UUID session IDs (any version 1–8)
10
10
  *
11
11
  * Closes #572 — Epic #568 Phase 2.1 (Parallel-Aware Sessions Semantic ID)
12
12
  * Closes #585 — Epic #583 W2-I2 (history-aware n-increment) per audit
@@ -67,14 +67,25 @@ import { parseStateMd } from './state-md/yaml-parser.mjs';
67
67
  export const SEMANTIC_ID_RE = /^([a-z0-9._/-]+)-(\d{4}-\d{2}-\d{2})-([a-z-]+)-(\d+)$/;
68
68
 
69
69
  /**
70
- * Regex for UUID-v4 session IDs.
70
+ * Regex for RFC 9562 UUID session IDs — ANY version 1–8, variant `10xx`.
71
71
  *
72
- * Matches: 8-4-4-4-12 hex digits, version nibble = '4', variant nibble in {8,9,a,b}.
73
- * Case-insensitive to accept both uppercase and lowercase hex.
72
+ * Matches: 8-4-4-4-12 hex digits, version nibble in [1-8], variant nibble in
73
+ * {8,9,a,b}. Case-insensitive to accept both uppercase and lowercase hex.
74
+ *
75
+ * Why the version nibble is a RANGE and not the literal `4` (Kanevry#66 / #1091):
76
+ * Claude Code mints UUIDv4 session ids, but Codex CLI mints UUIDv7. Pinning `4`
77
+ * made `parseSessionId()` return `null` for every Codex session, so
78
+ * `hooks/on-session-start.mjs` fell through to a freshly generated
79
+ * `randomUUID()` and every later hook in that session missed the lock.
80
+ *
81
+ * The structure stays strict on purpose: the dash/length layout and the
82
+ * variant nibble are what discriminate a real UUID from a 36-char lookalike,
83
+ * so only the version nibble is widened. Version `0` (nil UUID) and `9`..`f`
84
+ * (unassigned / max UUID) remain rejected — RFC 9562 defines 1–8.
74
85
  *
75
86
  * @type {RegExp}
76
87
  */
77
- export const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
88
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
78
89
 
79
90
  // ---------------------------------------------------------------------------
80
91
  // Internal helpers
@@ -344,8 +355,12 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
344
355
  * 1. Semantic: `<branch>-<YYYY-MM-DD>-<mode>-<n>`
345
356
  * Returns `{ format: 'semantic', branch, date, mode, n, raw }`.
346
357
  *
347
- * 2. UUID-v4: `xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx`
348
- * Returns `{ format: 'uuid', uuid, raw }`.
358
+ * 2. RFC 9562 UUID, any version 1–8, variant `10xx`:
359
+ * `xxxxxxxx-xxxx-[1-8]xxx-[89ab]xxx-xxxxxxxxxxxx`
360
+ * Returns `{ format: 'uuid', uuid, version, raw }`, where `version` is the
361
+ * version nibble as an integer (4 for Claude Code's v4 ids, 7 for Codex
362
+ * CLI's v7 ids). Callers that only branch on `format` are unaffected —
363
+ * `version` is additive (Kanevry#66 / #1091).
349
364
  *
350
365
  * Returns `null` for any input that is not a non-empty string or does not
351
366
  * match either known format. Never throws.
@@ -356,7 +371,7 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
356
371
  *
357
372
  * @param {unknown} id - The session ID to parse.
358
373
  * @returns {{ format: 'semantic', branch: string, date: string, mode: string, n: number, raw: string }
359
- * | { format: 'uuid', uuid: string, raw: string }
374
+ * | { format: 'uuid', uuid: string, version: number, raw: string }
360
375
  * | null}
361
376
  */
362
377
  export function parseSessionId(id) {
@@ -375,9 +390,10 @@ export function parseSessionId(id) {
375
390
  };
376
391
  }
377
392
 
378
- // Try UUID-v4.
379
- if (UUID_V4_RE.test(id)) {
380
- return { format: 'uuid', uuid: id, raw: id };
393
+ // Try RFC 9562 UUID (any version 1–8). Index 14 is the version nibble, and
394
+ // UUID_RE has already constrained it to [1-8], so Number() cannot be NaN.
395
+ if (UUID_RE.test(id)) {
396
+ return { format: 'uuid', uuid: id, version: Number(id[14]), raw: id };
381
397
  }
382
398
 
383
399
  return null;
@@ -418,9 +434,9 @@ export function parseSessionId(id) {
418
434
  * cannot assign the same n. The #952 collision was NOT a concurrency defect —
419
435
  * the lock held; the candidate set was incomplete.
420
436
  *
421
- * UUID-v4 entries (in any source) are silently dropped (parseSessionId returns
422
- * format:'uuid' which the filter excludes). Malformed semantic-looking IDs are
423
- * also dropped (SEMANTIC_ID_RE rejects them).
437
+ * UUID entries of ANY version (in any source) are silently dropped
438
+ * (parseSessionId returns format:'uuid' which the filter excludes). Malformed
439
+ * semantic-looking IDs are also dropped (SEMANTIC_ID_RE rejects them).
424
440
  *
425
441
  * @param {object} opts
426
442
  * @param {string} opts.branch - Current git branch (e.g. "main", "feature/foo").
@@ -0,0 +1,159 @@
1
+ /**
2
+ * own-session.mjs — "which session am I, and does this shared artefact belong to me?"
3
+ *
4
+ * A working copy is shared; a session is not. Every `.orchestrator/` and
5
+ * `<state-dir>/` artefact in this repo is written into the WORKING COPY, so a
6
+ * second live session reads the first one's files as if they were its own. The
7
+ * damage is always invisible to the writer: a peer's corrective hints briefing
8
+ * this session's fixer (#1058), a peer's `allowedPaths: []` locking this
9
+ * session out of every write (#1082/#1123).
10
+ *
11
+ * This module is the reusable half of that check, split into two pure-ish
12
+ * functions so a caller can resolve identity once and classify many artefacts:
13
+ *
14
+ * - {@link readOwnSessionIds} — every id that provably names THIS session.
15
+ * - {@link classifyManifestSession} — own / foreign / unknown for a manifest.
16
+ *
17
+ * Semantics were lifted from the `current-session.json` ownership check in
18
+ * `scripts/lib/quality-gate.mjs` (#1058), which is module-private there and sits
19
+ * behind a module this repo's hook guard-source-loader cannot bind. This is a
20
+ * deliberate re-implementation of the SEMANTICS, not a re-export.
21
+ *
22
+ * **Only what is PROVABLY foreign is foreign.** Every unprovable case returns
23
+ * `'unknown'`, and every caller is expected to treat `'unknown'` exactly as it
24
+ * behaved before this check existed. An ownership check that guesses turns
25
+ * "cannot tell" into a silent feature-off on every harness that exports no
26
+ * session id.
27
+ */
28
+
29
+ import { readLock } from '../session-lock.mjs';
30
+
31
+ /**
32
+ * The set of session ids that provably name THIS session — the UNION of every
33
+ * tier, never the first one that answers.
34
+ *
35
+ * Three sources, all read, all merged:
36
+ *
37
+ * 1. `hookInput` — the harness's own statement about the invocation being
38
+ * handled right now (`session_id` / `sessionId`, plus `parent_session_id`
39
+ * for a sub-agent invocation, whose coordinator is equally us). The only
40
+ * tier that is per-INVOCATION rather than per-working-copy.
41
+ * 2. `CLAUDE_CODE_SESSION_ID` — process-scoped, absent on harnesses that
42
+ * export no session env var.
43
+ * 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
44
+ * the identity the WRITER of a manifest uses: `wave-scope.json`'s
45
+ * `session` field comes from `sessionAttribution()`, which reads this same
46
+ * lock (`skills/wave-executor/wave-loop.md` § Scope Manifest 1).
47
+ *
48
+ * **Why the union, and not first-tier-wins.** Any id the process can
49
+ * legitimately claim — its own invocation, its harness env, the repo's live
50
+ * lock — names this session; only an id in NONE of them is somebody else's.
51
+ * Gating the tiers made the READER's identity a strict subset of the WRITER's,
52
+ * and three distinct ways of diverging all landed on the same silent failure —
53
+ * the OWN manifest classified `foreign`, so the write gate switched itself off
54
+ * for the whole wave, with an event that reads exactly like correct behaviour:
55
+ *
56
+ * - **Nested-harness divergence.** The payload `session_id` and
57
+ * `CLAUDE_CODE_SESSION_ID` disagree in a nested harness — already measured
58
+ * and documented in `resolveSessionId()` of
59
+ * `hooks/pre-bash-issue-budget.mjs`: *"stdin still wins: it is the id of
60
+ * THIS tool call, whereas the env var is the id of the process tree, and
61
+ * the two differ in a nested harness"*, alongside the measurement that the
62
+ * env var equals the `session.lock` `session_id` and survives into
63
+ * subagents. Under tier-gating, the payload alone decided.
64
+ * - **Sub-agent invocation.** A dispatched agent's own set was
65
+ * `{subagent-uuid}` while the manifest names the coordinator.
66
+ * `parent_session_id` is in tier 1 too, but a payload that carries only
67
+ * `session_id` still hid the coordinator's env/lock ids behind the gate.
68
+ * - **Peer-owned lock.** A second session that failed to acquire the lock
69
+ * (`bootstrapLock()` reason `active`, the lock keeps the PEER's id) writes
70
+ * that peer id into its OWN manifest via `sessionAttribution()`. Its own
71
+ * hook then read the payload tier, never reached the lock, and disarmed
72
+ * itself against the manifest it had just written.
73
+ *
74
+ * **The security direction is unchanged: the union only ADDS ids this process
75
+ * actually carries.** A manifest whose id appears in NO tier — not the
76
+ * invocation, not the env, not the lock — still classifies `foreign`, exactly
77
+ * as before; nothing here invents an id or widens what counts as a match.
78
+ *
79
+ * The cost is named rather than hidden, and it points the fail-CLOSED way: when
80
+ * the lock names a peer, that peer's manifest now reads `own`, so we ENFORCE a
81
+ * wave plan that is not ours. That is a visible, actionable deny — the inverse
82
+ * of tier-gating's failure, which was a silent enforcement-off. `unknown` still
83
+ * means unknown: an empty set can only produce `unknown`, never a mismatch.
84
+ *
85
+ * Every value is `.trim()`ed before it enters the set: a whitespace-only env
86
+ * var is truthy and would otherwise enter as a PHANTOM id that matches nothing
87
+ * — which would make every manifest read `foreign` and switch enforcement off
88
+ * (`.claude/rules/development.md` § env-var whitespace trap).
89
+ *
90
+ * Never throws.
91
+ *
92
+ * @param {string} repoRoot — working copy root, for the `session.lock` tier.
93
+ * @param {{ hookInput?: object|null }} [opts]
94
+ * @returns {Set<string>} possibly EMPTY — an empty set means "identity
95
+ * unresolvable", which {@link classifyManifestSession} treats as `unknown`,
96
+ * never as a mismatch.
97
+ */
98
+ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
99
+ const ids = new Set();
100
+ const add = (value) => {
101
+ const trimmed = typeof value === 'string' ? value.trim() : '';
102
+ if (trimmed) ids.add(trimmed);
103
+ };
104
+
105
+ // Source 1 — the harness's statement about THIS invocation.
106
+ if (hookInput && typeof hookInput === 'object') {
107
+ for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
108
+ }
109
+
110
+ // Source 2 — process-scoped env var.
111
+ add(process.env.CLAUDE_CODE_SESSION_ID);
112
+
113
+ // Source 3 — repo-global lock file (the manifest writer's own identity).
114
+ try {
115
+ const lock = readLock({ repoRoot });
116
+ for (const key of ['session_id', 'semantic_session_id']) add(lock?.[key]);
117
+ } catch {
118
+ /* readLock never throws by contract, but that contract is not ours to trust */
119
+ }
120
+ return ids;
121
+ }
122
+
123
+ /**
124
+ * Decide whether a wave-scope manifest belongs to THIS session.
125
+ *
126
+ * Three outcomes, and the middle one is load-bearing:
127
+ *
128
+ * - `'foreign'` — the manifest names at least one session id, we know at
129
+ * least one of our own, and NONE of them match. The only verdict that
130
+ * changes behaviour.
131
+ * - `'unknown'` — the manifest names no id (a legacy manifest written before
132
+ * the `session` field existed), or we could not resolve our own. Ownership
133
+ * is unproven in BOTH directions, so the caller must keep doing exactly
134
+ * what it did before.
135
+ * - `'own'` — an id matched.
136
+ *
137
+ * Both id fields are consulted because they address the same session under two
138
+ * naming schemes: `session` is the raw harness session id (a UUID on Claude
139
+ * Code), `semantic_session` the `<branch>-<date>-<mode>-<n>` form. A harness
140
+ * that resolves only the semantic one must still recognise its own manifest.
141
+ *
142
+ * @param {unknown} scope — parsed wave-scope manifest (any shape; a non-object
143
+ * simply yields no ids, hence `'unknown'`).
144
+ * @param {Set<string>} ownIds — from {@link readOwnSessionIds}.
145
+ * @returns {{ verdict: 'own'|'foreign'|'unknown', manifestIds: string[] }}
146
+ */
147
+ export function classifyManifestSession(scope, ownIds) {
148
+ const manifestIds = [];
149
+ if (scope && typeof scope === 'object' && !Array.isArray(scope)) {
150
+ for (const key of ['session', 'semantic_session']) {
151
+ const value = typeof scope[key] === 'string' ? scope[key].trim() : '';
152
+ if (value) manifestIds.push(value);
153
+ }
154
+ }
155
+ const own = ownIds instanceof Set ? ownIds : new Set();
156
+ if (manifestIds.length === 0 || own.size === 0) return { verdict: 'unknown', manifestIds };
157
+ const matched = manifestIds.some((id) => own.has(id));
158
+ return { verdict: matched ? 'own' : 'foreign', manifestIds };
159
+ }
@@ -45,6 +45,7 @@ import crypto from 'node:crypto';
45
45
  import { classifyMode } from './exclusivity-matrix.mjs';
46
46
  import { isPidAliveOnHost } from './file-lock.mjs';
47
47
  import { writeJsonAtomicSync } from './io.mjs';
48
+ import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
48
49
 
49
50
  // isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
50
51
  // owns it so the dependency edge points file-lock → io, never the reverse).
@@ -101,11 +102,12 @@ export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json'
101
102
  // NOT the discovery-path liveness check — since Epic #583 the discovery
102
103
  // decision tree uses heartbeat-age via {@link isLockLive} instead, because the
103
104
  // `pid` recorded on a session.lock is the *ephemeral hook subprocess* PID.
104
- // Same-host callers (`acquire`, `checkStale`, and the state-lock /
105
- // staging-fence stale-override paths, now via the file-lock primitive) use it
106
- // only for the short-lived stale-override path where the recorded PID IS the
107
- // live writer's PID. See file-lock.mjs for the full @forensic + PID-recycle
108
- // trade-off note.
105
+ // NOTHING IN THIS MODULE CALLS IT any more: `acquire()` stopped consulting the
106
+ // pid in #744/#1137 and `checkStale()` in #1151 — the re-export is a
107
+ // compatibility surface for external importers only. The remaining production
108
+ // callers are file-lock.mjs's own stale-override path and lock-reaper.mjs,
109
+ // where the recorded PID IS the process being asked about. See file-lock.mjs
110
+ // for the full @forensic + PID-recycle trade-off note.
109
111
 
110
112
  /**
111
113
  * Resolve the absolute path to the lock file.
@@ -146,6 +148,28 @@ function lockAgeHours(lock) {
146
148
  return (Date.now() - ts) / (3600 * 1000);
147
149
  }
148
150
 
151
+ /**
152
+ * Compute the age of a lock's heartbeat in fractional minutes.
153
+ *
154
+ * This is the diagnostic counterpart to `isLockLive()` — the SAME quantity the
155
+ * liveness rule thresholds against, surfaced as a number so callers (the
156
+ * Phase-1.2 stale-lock AUQ, recovery diagnostics) can report WHY a lock was
157
+ * classified stale instead of asserting a PID verdict the lock cannot support
158
+ * (#1137). Mirrors `isLockLive()`'s `last_heartbeat` → `started_at` fallback.
159
+ *
160
+ * @param {{ last_heartbeat?: string, started_at?: string }} lock
161
+ * @returns {number|null} minutes since the last heartbeat, or null if unparseable.
162
+ */
163
+ function heartbeatAgeMinutes(lock) {
164
+ if (!lock || typeof lock !== 'object') return null;
165
+ const hbStr = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
166
+ ? lock.last_heartbeat
167
+ : lock.started_at;
168
+ const ts = Date.parse(hbStr);
169
+ if (Number.isNaN(ts)) return null;
170
+ return (Date.now() - ts) / (60 * 1000);
171
+ }
172
+
149
173
  /**
150
174
  * Parse lock file contents into an object. Returns null on any parse error.
151
175
  *
@@ -213,13 +237,22 @@ function parseLock(raw) {
213
237
  */
214
238
  function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
215
239
  const startedAt = nowIso();
240
+ // Writing a session lock is the one moment we KNOW the current os.hostname()
241
+ // belongs to this machine — record it so a later reading under a different
242
+ // spelling can still be recognised as the same host (#1072). Best-effort:
243
+ // recordHostAlias never throws, and a failed write only costs the alias.
244
+ recordHostAlias();
216
245
  const lock = {
217
246
  session_id: sessionId,
218
247
  started_at: startedAt,
219
248
  last_heartbeat: startedAt,
220
249
  mode,
221
250
  pid: process.pid,
251
+ // `host` stays the RAW hostname — it is an on-the-wire event field
252
+ // (orchestrator.session.lock.acquired) and feeds the privacy-hash contract.
253
+ // `host_id` is the additive normalised twin every comparison reads (#1072).
222
254
  host: os.hostname(),
255
+ host_id: stableHostname(),
223
256
  ttl_hours: ttlHours,
224
257
  };
225
258
  if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
@@ -455,10 +488,15 @@ export function readLockDetailed(opts = {}) {
455
488
  * — lock created
456
489
  * { ok: false, reason: 'active', existingLock, exclusivityClass? }
457
490
  * — local lock held (live TTL, live PID)
458
- * { ok: false, reason: 'stale-pid-dead', existingLock, exclusivityClass? }
459
- * — local lock stale (dead PID)
460
- * { ok: false, reason: 'stale-pid-alive', existingLock, exclusivityClass? }
461
- * local lock stale (live PID, TTL expired)
491
+ * { ok: false, reason: 'stale-heartbeat', existingLock, ageHours, heartbeatAgeMinutes, exclusivityClass? }
492
+ * — local lock stale: its last_heartbeat is older than ttl_hours. This is
493
+ * the ONLY stale reason (#1137). It replaced the `stale-pid-dead` /
494
+ * `stale-pid-alive` pair, which claimed a PID verdict the lock cannot
495
+ * support: the recorded `pid` is the ephemeral hook / `node -e`
496
+ * subprocess, dead within ~1s of genesis (measured 2026-08-23: 7 of 7
497
+ * recorded pids dead, INCLUDING the currently heartbeating session's
498
+ * own lock), so `stale-pid-alive` was unreachable same-host and every
499
+ * stale lock rendered as "confirmed dead" in the recovery AUQ.
462
500
  * { ok: false, reason: 'fs-error', error, exclusivityClass? }
463
501
  * — filesystem failure
464
502
  * { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
@@ -563,12 +601,8 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
563
601
  try {
564
602
  // Classify an existing lock into the correct failure result. Shared by the
565
603
  // up-front readLock() check AND the create-race EEXIST-loser path below so
566
- // both report identical active / stale-pid-dead / stale-pid-alive reasons.
604
+ // both report identical active / stale-heartbeat reasons.
567
605
  const classifyExisting = (existing) => {
568
- const sameHost = existing.host === os.hostname();
569
- // PID liveness is only meaningful on the same host.
570
- const pidAlive = sameHost ? isPidAliveOnHost(existing.pid) : null;
571
-
572
606
  // Heartbeat-first liveness (#744): isLockLive is the SOLE active gate.
573
607
  // A dead recorded PID must NOT veto a fresh last_heartbeat — the pid on
574
608
  // a session.lock is the ephemeral hook subprocess PID, not the semantic
@@ -581,11 +615,24 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
581
615
  return { ok: false, reason: 'active', existingLock: existing, exclusivityClass: callerClass };
582
616
  }
583
617
 
584
- // Heartbeat expired — classify the stale variant. Cross-host locks never
585
- // have a confirmable dead PID (pidAlive stays null), so they always land
586
- // on 'stale-pid-alive' rather than 'stale-pid-dead'.
587
- const reason = (pidAlive === false) ? 'stale-pid-dead' : 'stale-pid-alive';
588
- return { ok: false, reason, existingLock: existing, exclusivityClass: callerClass };
618
+ // Heartbeat expired — ONE stale reason, derived from the same signal the
619
+ // active gate above used (#1137). The former two-way split asked
620
+ // isPidAliveOnHost(existing.pid) and reported 'stale-pid-dead' /
621
+ // 'stale-pid-alive'; that question has no answer the lock can give,
622
+ // because `pid` is the short-lived writer subprocess, not the session.
623
+ // Same-host it was therefore ~always 'dead' (7/7 measured, live sessions
624
+ // included) and 'stale-pid-alive' was structurally unreachable. The
625
+ // ageHours + heartbeatAgeMinutes fields carry the evidence instead, so a
626
+ // recovery prompt can state the measured heartbeat age rather than a
627
+ // liveness verdict.
628
+ return {
629
+ ok: false,
630
+ reason: 'stale-heartbeat',
631
+ existingLock: existing,
632
+ ageHours: lockAgeHours(existing),
633
+ heartbeatAgeMinutes: heartbeatAgeMinutes(existing),
634
+ exclusivityClass: callerClass,
635
+ };
589
636
  };
590
637
 
591
638
  const existing = readLock({ repoRoot });
@@ -978,7 +1025,7 @@ export function updateHeartbeat({ repoRoot, sessionId } = {}) {
978
1025
  * lock: object|null,
979
1026
  * ageHours: number|null,
980
1027
  * ttlExpired: boolean,
981
- * pidAlive: boolean|null,
1028
+ * heartbeatAgeMinutes: number|null,
982
1029
  * host: string|null,
983
1030
  * sameHost: boolean,
984
1031
  * isLive: boolean
@@ -993,7 +1040,7 @@ export function checkStale({ repoRoot } = {}) {
993
1040
  lock: null,
994
1041
  ageHours: null,
995
1042
  ttlExpired: false,
996
- pidAlive: null,
1043
+ heartbeatAgeMinutes: null,
997
1044
  host: null,
998
1045
  sameHost: false,
999
1046
  isLive: false,
@@ -1002,14 +1049,22 @@ export function checkStale({ repoRoot } = {}) {
1002
1049
 
1003
1050
  const ageHours = lockAgeHours(lock);
1004
1051
  const ttlExpired = isTtlExpired(lock);
1005
- const sameHost = lock.host === os.hostname();
1006
- // Only attempt PID check when the lock was written on this machine.
1007
- const pidAlive = sameHost ? isPidAliveOnHost(lock.pid) : null;
1008
- // Heartbeat-based liveness (#744) additive field alongside the pre-existing
1009
- // ttlExpired/pidAlive/sameHost fields (back-compat). This is the SAME check
1010
- // acquire()'s classifyExisting now uses as its sole active gate, surfaced
1011
- // here so callers of checkStale() (recovery-flow diagnostics) can observe
1012
- // when isLive diverges from the legacy pidAlive/ttlExpired signals.
1052
+ // #1072: alias-aware, not a raw os.hostname() comparison — this machine's
1053
+ // hostname flips spelling, which made `sameHost` false for its own lock.
1054
+ const sameHost = hostnamesMatch(lockHostCandidate(lock), os.hostname());
1055
+ // NO `pidAlive` FIELD (#1151). #1137 kept it as an always-null shape stub;
1056
+ // nothing ever read it measured @ f0766e1, zero production readers
1057
+ // repo-wide. Probing isPidAliveOnHost(lock.pid) answered a question about the
1058
+ // ephemeral writer subprocess, not the session: 2026-08-23, 7 of 7 recorded
1059
+ // pids were dead, including the lock of the session that was heartbeating at
1060
+ // that very moment, so a `false` here read as "the session is dead" and was
1061
+ // wrong every time. `isLive` is the verdict and `heartbeatAgeMinutes` the
1062
+ // magnitude behind it. `isPidAliveOnHost` itself stays exported —
1063
+ // file-lock.mjs and lock-reaper.mjs are legitimate callers, where the pid IS
1064
+ // the process being asked about.
1065
+ // Heartbeat-based liveness (#744) — the SAME check acquire()'s
1066
+ // classifyExisting uses as its sole active gate, surfaced here so callers of
1067
+ // checkStale() (recovery-flow diagnostics) can observe it directly.
1013
1068
  const isLive = isLockLive(lock);
1014
1069
 
1015
1070
  return {
@@ -1017,7 +1072,7 @@ export function checkStale({ repoRoot } = {}) {
1017
1072
  lock,
1018
1073
  ageHours,
1019
1074
  ttlExpired,
1020
- pidAlive,
1075
+ heartbeatAgeMinutes: heartbeatAgeMinutes(lock),
1021
1076
  host: lock.host,
1022
1077
  sameHost,
1023
1078
  isLive,