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
@@ -28,6 +28,7 @@ import path from 'node:path';
28
28
  import crypto from 'node:crypto';
29
29
 
30
30
  import { tryAcquireFileLock } from '../file-lock.mjs';
31
+ import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
31
32
  import { nowIso, delay, parseLockBody } from './lock-body.mjs';
32
33
 
33
34
  // ---------------------------------------------------------------------------
@@ -67,7 +68,9 @@ function stagingFenceLockPathFor(repoRoot) {
67
68
  function buildStagingFenceLockBody({ holder }) {
68
69
  return {
69
70
  pid: process.pid,
71
+ // `host` raw + `host_id` normalised (#1072) — see buildStateLockBody.
70
72
  host: os.hostname(),
73
+ host_id: stableHostname(),
71
74
  acquiredAt: nowIso(),
72
75
  holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
73
76
  };
@@ -178,7 +181,8 @@ export function releaseStagingFenceLock({ repoRoot, holder } = {}) {
178
181
 
179
182
  const ownerMatch = typeof holder === 'string' && holder.length > 0
180
183
  ? lock.holder === holder
181
- : lock.pid === process.pid && lock.host === os.hostname();
184
+ // #1072: alias-aware host identity, not a raw os.hostname() comparison.
185
+ : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
182
186
 
183
187
  if (!ownerMatch) {
184
188
  return { ok: false, reason: 'not-owner' };
@@ -41,6 +41,7 @@ import crypto from 'node:crypto';
41
41
 
42
42
  import { _parseStateMdLock } from '../config/state-md-lock.mjs';
43
43
  import { tryAcquireFileLock } from '../file-lock.mjs';
44
+ import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
44
45
  import { nowIso, delay, parseLockBody } from './lock-body.mjs';
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -78,7 +79,11 @@ function stateLockPathFor(repoRoot) {
78
79
  function buildStateLockBody({ holder }) {
79
80
  return {
80
81
  pid: process.pid,
82
+ // `host` raw + `host_id` normalised (#1072). The written body comes from
83
+ // tryAcquireFileLock, which carries the same pair — this keeps the shape
84
+ // declared here truthful about what lands on disk.
81
85
  host: os.hostname(),
86
+ host_id: stableHostname(),
82
87
  acquiredAt: nowIso(),
83
88
  holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
84
89
  };
@@ -224,7 +229,9 @@ export function releaseStateLock({ repoRoot, sessionId, holder } = {}) {
224
229
  const expectedHolder = holder ?? sessionId ?? null;
225
230
  const ownerMatch = expectedHolder !== null
226
231
  ? lock.holder === expectedHolder
227
- : lock.pid === process.pid && lock.host === os.hostname();
232
+ // #1072: alias-aware host identity a raw comparison strands the lock as
233
+ // 'not-owner' once os.hostname() flips spelling mid-session.
234
+ : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
228
235
 
229
236
  if (!ownerMatch) {
230
237
  return { ok: false, reason: 'not-owner' };
@@ -308,7 +308,7 @@ async function countMemoryFiles(dir) {
308
308
  *
309
309
  * @param {object} args
310
310
  * @param {string} args.repoRoot — absolute path
311
- * @param {string} [args.memoryDir] — defaults to `resolveMemoryDir()`
311
+ * @param {string} [args.memoryDir] — defaults to `resolveMemoryDir(repoRoot)`
312
312
  * @param {string} [args.learningsPath] — defaults to `<repoRoot>/.orchestrator/metrics/learnings.jsonl`
313
313
  * @param {Date} [args.now] — injectable clock for tests
314
314
  * @param {{enabled?: boolean, 'half-life-days'?: number, 'floor-factor'?: number}} [args.evolveDecay]
@@ -326,9 +326,12 @@ export async function readBannerInputs({ repoRoot, memoryDir, learningsPath, now
326
326
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
327
327
  throw new TypeError('readBannerInputs: repoRoot is required (absolute path)');
328
328
  }
329
+ // #1071: bind the memory dir to the SAME root as learnings/sessions/peer-cards
330
+ // below. Called with no argument it derived from the ambient `process.cwd()`,
331
+ // which reported `0 memory files` beside a 245-session count from `repoRoot`.
329
332
  const memDir = typeof memoryDir === 'string' && memoryDir.length > 0
330
333
  ? memoryDir
331
- : resolveMemoryDir();
334
+ : resolveMemoryDir(repoRoot);
332
335
  const learningsFile = typeof learningsPath === 'string' && learningsPath.length > 0
333
336
  ? learningsPath
334
337
  : path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
@@ -15,17 +15,26 @@ import os from 'node:os';
15
15
  /**
16
16
  * Resolve the project-specific memory directory used by the Claude Code harness.
17
17
  *
18
- * Mirrors the harness convention: `~/.claude/projects/<encoded-cwd>/memory/`
19
- * where `<encoded-cwd>` is the cwd with BOTH `/` AND `.` replaced by `-`. The
20
- * dot replacement matters for users with a trailing-`.` in their home dir
21
- * (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
18
+ * Mirrors the harness convention: `~/.claude/projects/<encoded-root>/memory/`
19
+ * where `<encoded-root>` is the project root with BOTH `/` AND `.` replaced by
20
+ * `-`. The dot replacement matters for users with a trailing-`.` in their home
21
+ * dir (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
22
22
  * what the harness actually wrote.
23
23
  *
24
24
  * Verified empirically against `~/.claude/projects/` directory naming.
25
25
  *
26
+ * `repoRoot` is explicit (#1071) because every OTHER banner input is already
27
+ * bound to it: with the memory dir alone derived from the ambient `process.cwd()`,
28
+ * a caller reading sessions/learnings/peer-cards out of the primary checkout
29
+ * reported `0 memory files` whenever the process ran from a worktree or a
30
+ * subdirectory. Defaulted, so existing zero-arg callers keep their behaviour.
31
+ *
32
+ * @param {string} [repoRoot] — absolute project root; defaults to `process.cwd()`.
26
33
  * @returns {string} Absolute path to the memory directory (not guaranteed to exist).
27
34
  */
28
- export function resolveMemoryDir() {
29
- const encoded = process.cwd().replaceAll('/', '-').replaceAll('.', '-');
35
+ export function resolveMemoryDir(repoRoot = process.cwd()) {
36
+ const root =
37
+ typeof repoRoot === 'string' && repoRoot.length > 0 ? repoRoot : process.cwd();
38
+ const encoded = root.replaceAll('/', '-').replaceAll('.', '-');
30
39
  return path.join(os.homedir(), '.claude', 'projects', encoded, 'memory');
31
40
  }
@@ -82,6 +82,57 @@ export function safeBootstrapLock(signals) {
82
82
  : null;
83
83
  }
84
84
 
85
+ /**
86
+ * Read a session record's completion rate, tolerating both the shape
87
+ * `sessions.jsonl` actually writes and the legacy flat one.
88
+ *
89
+ * Production records nest it: `{effectiveness: {completion_rate: 0.77, …}}`.
90
+ * The top-level `completion_rate` key is ABSENT — not present-but-null, which
91
+ * an earlier revision of this comment claimed. Measured 2026-08-28 @ 7daa3d2
92
+ * over `.orchestrator/metrics/sessions.jsonl` (281 records):
93
+ * `jq -s '{hasTopLevelKey: ([.[]|select(has("completion_rate"))]|length), …}'`
94
+ * → `hasTopLevelKey: 0`, `nested: 182`, and 99 records carrying NO completion
95
+ * rate in either shape. Reading only the flat field therefore made every
96
+ * average 0 in production — see #1071.
97
+ *
98
+ * Precedence mirrors `completionOf()` in
99
+ * `scripts/lib/evolve/autopilot-effectiveness.mjs`: nested canonical fields
100
+ * first, flat legacy fields as a backward-compatible fallback for
101
+ * hand-written/pre-nesting records.
102
+ *
103
+ * @param {unknown} session
104
+ * @returns {number} completion rate, or 0 when unknown
105
+ */
106
+ function completionRateOf(session) {
107
+ if (session === null || typeof session !== 'object') return 0;
108
+ const eff = (session.effectiveness !== null && typeof session.effectiveness === 'object')
109
+ ? session.effectiveness
110
+ : {};
111
+ const raw =
112
+ eff.completion_rate ?? eff.completion_ratio ?? session.completion_rate ?? session.completion_ratio;
113
+ return typeof raw === 'number' && !Number.isNaN(raw) ? raw : 0;
114
+ }
115
+
116
+ /**
117
+ * Mean completion rate across a session slice. Unknown values contribute 0 and
118
+ * the divisor stays the slice length — an incomplete slice can never inflate
119
+ * the average past the >= 0.9 bonus threshold.
120
+ *
121
+ * The divisor is the load-bearing half, not the numerator: 99 of the 281
122
+ * records in production carry no completion rate in EITHER shape (measured
123
+ * 2026-08-28 @ 7daa3d2), so a divisor of "known values only" would let a single
124
+ * 1.0 session beside two unknowns score a perfect 1.0 and hand out the trend
125
+ * bonus on one data point. Pinned by the divisor test in
126
+ * `tests/lib/mode-selector/scoring.test.mjs`.
127
+ *
128
+ * @param {any[]} sessions
129
+ * @returns {number}
130
+ */
131
+ function avgCompletionRate(sessions) {
132
+ if (sessions.length === 0) return 0;
133
+ return sessions.reduce((sum, s) => sum + completionRateOf(s), 0) / sessions.length;
134
+ }
135
+
85
136
  // ---------------------------------------------------------------------------
86
137
  // Public API
87
138
  // ---------------------------------------------------------------------------
@@ -107,9 +158,7 @@ export function computeDelta(candidateMode, signals) {
107
158
  (s) => typeof s === 'object' && s !== null && s.session_type === candidateMode,
108
159
  );
109
160
  if (allMatch) {
110
- const avgCompletion =
111
- last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
112
- 3;
161
+ const avgCompletion = avgCompletionRate(last3);
113
162
  trendBonus = avgCompletion >= 0.9 ? 0.15 : 0.075;
114
163
  }
115
164
  }
@@ -151,9 +200,7 @@ export function computeDelta(candidateMode, signals) {
151
200
  (s) => typeof s === 'object' && s !== null && s.session_type === otherMode,
152
201
  );
153
202
  if (allOther) {
154
- const avgCompletion =
155
- last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
156
- 3;
203
+ const avgCompletion = avgCompletionRate(last3);
157
204
  if (avgCompletion >= 0.9) {
158
205
  penalty += 0.10;
159
206
  break; // only one other dominant mode possible
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync, statSync } from 'node:fs';
12
+ import os from 'node:os';
12
13
  import path from 'node:path';
13
14
  import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
14
15
 
@@ -42,9 +43,66 @@ function _hasEnvValue(envName) {
42
43
  }
43
44
 
44
45
  /**
45
- * Walk up the directory tree from startDir looking for marker.
46
- * Uses path.parse(dir).root so it terminates correctly on Windows ("C:\\")
47
- * and POSIX ("/").
46
+ * Absolute home directory, or null when the host cannot report one.
47
+ * os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows.
48
+ *
49
+ * @returns {string|null}
50
+ */
51
+ function _homeDir() {
52
+ try {
53
+ const home = os.homedir();
54
+ return home ? path.resolve(home) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * True when `dir` is the home directory itself or one of its ancestors
62
+ * ("/", "/Users", "C:\\", …). Unrelated branches of the tree (e.g. "/opt")
63
+ * are neither — they are walked normally.
64
+ *
65
+ * @param {string} dir
66
+ * @param {string|null} home
67
+ * @returns {boolean}
68
+ */
69
+ function _isHomeOrAbove(dir, home) {
70
+ if (home === null) return false;
71
+ if (dir === home) return true;
72
+ // The filesystem root already ends in the separator ("/", "C:\\") — appending
73
+ // a second one ("//") would make it match nothing and silently exempt the one
74
+ // directory that is an ancestor of every home.
75
+ const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
76
+ return home.startsWith(prefix);
77
+ }
78
+
79
+ /**
80
+ * Walk up the directory tree from startDir looking for marker, bounded by the
81
+ * project the walk started in.
82
+ *
83
+ * Two boundaries, both load-bearing (#1139) — a marker outside the project can
84
+ * never describe the project:
85
+ *
86
+ * 1. **Repo root.** The first ancestor holding `.git` is the LAST directory
87
+ * inspected (inclusive — a marker at the repo root is still found). `.git`
88
+ * is a directory in a normal clone and a FILE in a worktree or submodule,
89
+ * so existence is checked, not the kind.
90
+ * 2. **Home directory.** os.homedir() and its ancestors are never inspected —
91
+ * even when no repo root was found (cwd outside every checkout). This is
92
+ * what stops a stray `~/.pi` / `~/.cursor/rules` / `~/CLAUDE.md` from being
93
+ * adopted by every markerless directory on the host: before this boundary
94
+ * existed, 63 of 84 telemetry records from Claude Code sessions reported
95
+ * `platform=pi` because the walk reached `$HOME` and found `~/.pi` there.
96
+ *
97
+ * Named ceiling (BV-004): when a checkout's root IS the home directory (a
98
+ * dotfiles repo at `$HOME`), boundary 2 wins over boundary 1 and no marker is
99
+ * detected there — deliberate, because that false positive is silent and
100
+ * host-wide while the false negative degrades to the documented default
101
+ * ('claude' for detectPlatform, cwd for resolveProjectDir). Revisit if a
102
+ * repo-at-$HOME layout ever has to carry orchestrator state.
103
+ *
104
+ * Terminates correctly on Windows ("C:\\") and POSIX ("/") via the
105
+ * parent === dir fixpoint.
48
106
  *
49
107
  * @param {string} startDir Absolute directory to begin walking from
50
108
  * @param {string} marker Relative sub-path to look for inside each candidate dir
@@ -53,7 +111,7 @@ function _hasEnvValue(envName) {
53
111
  */
54
112
  function walkUpFor(startDir, marker, kind) {
55
113
  let dir = path.resolve(startDir);
56
- const root = path.parse(dir).root; // "/" on POSIX, "C:\\" on Windows
114
+ const home = _homeDir();
57
115
 
58
116
  const check = (candidate) => {
59
117
  if (!existsSync(candidate)) return false;
@@ -62,16 +120,15 @@ function walkUpFor(startDir, marker, kind) {
62
120
  return true; // 'any'
63
121
  };
64
122
 
65
- while (dir !== root) {
123
+ for (;;) {
124
+ if (_isHomeOrAbove(dir, home)) break; // boundary 2 — never inspected
66
125
  if (check(path.join(dir, marker))) return dir;
126
+ if (existsSync(path.join(dir, '.git'))) break; // boundary 1 — repo root was the last candidate
67
127
  const parent = path.dirname(dir);
68
- if (parent === dir) break; // safety guard — should not happen but protects against edge cases
128
+ if (parent === dir) break; // filesystem root reached
69
129
  dir = parent;
70
130
  }
71
131
 
72
- // Check root itself
73
- if (check(path.join(root, marker))) return root;
74
-
75
132
  return null;
76
133
  }
77
134
 
@@ -109,6 +166,12 @@ export function detectPlatform() {
109
166
  if (walkUpFor(cwd, path.join('.cursor', 'rules'), 'dir')) return 'cursor';
110
167
  if (walkUpFor(cwd, '.pi', 'dir')) return 'pi';
111
168
 
169
+ // Signal-free fallback, deliberate rather than inherited: Claude Code is the
170
+ // only harness that drives this code through `hooks.json` without exporting a
171
+ // platform env var, so it is the harness that actually reaches this line.
172
+ // Codex, Cursor and pi each set their own env var (step 2) or ship a marker
173
+ // directory (step 3), so a wrong answer here costs them nothing they had.
174
+ // No config key for this (BV-001) — SO_PLATFORM already overrides it.
112
175
  return 'claude';
113
176
  }
114
177
 
@@ -5,20 +5,45 @@
5
5
  * provides a deterministic, testable resolution strategy so hook handlers and
6
6
  * scripts never silently fall back to an empty path or wrong directory.
7
7
  *
8
- * Fallback order (stops at first success):
9
- * 1. PLUGIN_ROOT native env var
10
- * 2. Compatibility root matching explicit SO_PLATFORM
11
- * 3. Remaining Claude, Codex, Cursor, and Pi compatibility roots
12
- * 4. Walk up from import.meta.url looking for package.json whose name === "session-orchestrator"
13
- * 5. Walk up from process.cwd() looking for the same marker
8
+ * TIER ORDER — SINGLE SOURCE OF TRUTH (stops at first success):
9
+ * 1. PLUGIN_ROOT native env var
10
+ * 2-5. Compatibility roots: the one matching an explicit SO_PLATFORM first,
11
+ * then the rest in legacy Claude Codex Cursor Pi order
12
+ * 6. Walk up from import.meta.url looking for package.json whose
13
+ * name === "session-orchestrator"
14
+ * 7. Walk up from process.cwd() looking for the same marker
15
+ * 8. Scan the client plugin caches (marketplace install, no env, cwd outside
16
+ * any checkout — GH Kanevry/session-orchestrator#64)
14
17
  *
15
- * Throws PluginRootResolutionError when all resolution levels fail.
18
+ * `.mcp.json`'s bash bootstrap MIRRORS this order and cites this block. It
19
+ * implements: tiers 1-5 in this exact env order → `git rev-parse
20
+ * --show-toplevel` (its analogue of tier 7) → `node -e resolvePluginRoot()`
21
+ * (which runs THIS function, tiers 1-8) → its own cache scan, reachable only
22
+ * when the module is not node-resolvable at all.
23
+ *
24
+ * THE ONE REMAINING DIVERGENCE, stated exactly: **tier 6** — a shell has no
25
+ * `import.meta.url`, so the walk up from this file's own location has no
26
+ * analogue there. Everything else is mirrored byte-for-byte in behaviour: the
27
+ * env list AND its order, the `CODEX_HOME` trim (see `_pluginCacheBases`), the
28
+ * `package.json{name === "session-orchestrator"}` test (the shell runs the same
29
+ * `JSON.parse` via `node -e`, not a substring `grep` — a `grep` matched
30
+ * `"name": "session-orchestrator-fork"` that this function rejects), and the
31
+ * newest-wins-by-mtime tiebreak (`[ "$c" -nt "$r" ]` is strict, exactly like
32
+ * the `>` below).
16
33
  *
17
- * Backward compat: without native or explicit platform inputs, compatibility
18
- * roots retain their legacy Claude Codex Cursor Pi order.
34
+ * Two divergences that existed until 2026-08-28 and no longer do: the shell
35
+ * scanned the caches BEFORE the node tier (a host with both a global npm
36
+ * install and a marketplace cache copy had the shell pick the cache while this
37
+ * function picked the npm copy), and its env list read `CLAUDE_PLUGIN_ROOT,
38
+ * CODEX_PLUGIN_ROOT, PLUGIN_ROOT` — a different tier-1 winner, and no
39
+ * `CURSOR_RULES_DIR`/`PI_PLUGIN_ROOT` at all. When either side's order changes,
40
+ * change both — the shell is the mirror, this list is the original.
41
+ *
42
+ * Throws PluginRootResolutionError when all resolution levels fail.
19
43
  */
20
44
 
21
- import { existsSync, readFileSync, statSync } from 'node:fs';
45
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
46
+ import os from 'node:os';
22
47
  import path from 'node:path';
23
48
  import { fileURLToPath } from 'node:url';
24
49
 
@@ -157,6 +182,103 @@ function _walkUp(startDir) {
157
182
  return null;
158
183
  }
159
184
 
185
+ /**
186
+ * Base directories under which a client keeps its plugin cache.
187
+ *
188
+ * Measured 2026-08-28 on codex-cli 0.141.0 and Claude Code: an installed
189
+ * plugin is COPIED (not symlinked) to
190
+ * `<base>/plugins/cache/<marketplace>/<plugin-name>/<version>/`, e.g.
191
+ * `~/.codex/plugins/cache/local/session-orchestrator/<version>+codex.<stamp>/`
192
+ * and `~/.claude/plugins/cache/session-orchestrator/session-orchestrator/3.13.0/`.
193
+ *
194
+ * Cursor is deliberately absent: no cache layout was measured for it on this
195
+ * host (`~/.cursor/plugins/cache` did not exist), and a guessed path would
196
+ * resolve nothing while implying coverage. Add it once a real install is seen.
197
+ *
198
+ * `CODEX_HOME` is TRIMMED before the emptiness test, not merely defaulted.
199
+ * `||` and shell `${X:-…}` both fire on unset/empty only, so a whitespace-only
200
+ * value passes straight through and the scan globs a nonsense base
201
+ * (`.claude/rules/development.md` § Env-var fallback whitespace trap).
202
+ * `.mcp.json`'s bash bootstrap carries the same trim for the same reason —
203
+ * before 2026-08-28 it used `${CODEX_HOME:-$HOME/.codex}` and diverged from here on
204
+ * exactly that input.
205
+ *
206
+ * @returns {string[]} Absolute base directories, most-specific client first
207
+ */
208
+ function _pluginCacheBases() {
209
+ const home = os.homedir();
210
+ const codexHome = (process.env.CODEX_HOME || '').trim() || path.join(home, '.codex');
211
+ return [codexHome, path.join(home, '.claude')];
212
+ }
213
+
214
+ /**
215
+ * Scan the client plugin caches for an installed copy of this plugin.
216
+ *
217
+ * This is the only level that can succeed for a marketplace install launched
218
+ * outside any checkout. Measured 2026-08-28 by probing a registered MCP server
219
+ * from `/tmp` (codex-cli 0.141.0): the child process received NO plugin-root
220
+ * environment variable (`CLAUDE_PLUGIN_ROOT`, `CODEX_PLUGIN_ROOT`,
221
+ * `PLUGIN_ROOT` and `CODEX_HOME` were all unset), its `PWD` was the launch
222
+ * directory, and `HOME` was set — so every earlier level is blind while the
223
+ * installed copy sits at a well-known path under `HOME`.
224
+ * See GH Kanevry/session-orchestrator#64.
225
+ *
226
+ * Ceiling (BV-004): among valid candidates the newest wins by directory
227
+ * **mtime**, not by semver. That reads as "the copy the client installed most
228
+ * recently", which is the intent, and it keeps this resolver free of a semver
229
+ * parser — a lexical sort would already be wrong today (`3.9.0` sorts above
230
+ * `3.10.0`). Revisit if a client starts pre-seeding caches it never launches.
231
+ *
232
+ * Equal-mtime tiebreak: the comparison is STRICTLY greater, so on a tie the
233
+ * FIRST candidate in scan order wins — bases in `_pluginCacheBases()` order
234
+ * (`CODEX_HOME` before `~/.claude`), then `readdirSync` order within each. Two
235
+ * cache copies sharing an mtime to the millisecond means the same content
236
+ * installed twice, so the choice is arbitrary rather than wrong; what matters
237
+ * is that it is DETERMINISTIC and identical to `.mcp.json`'s `[ "$c" -nt "$r" ]`,
238
+ * which is also strict. Untested by design — a test would pin an arbitrary
239
+ * choice as a contract.
240
+ *
241
+ * @param {string[]} tried Diagnostic accumulator, appended to on failure
242
+ * @returns {string|null} Absolute path to the newest cached plugin copy
243
+ */
244
+ function _scanPluginCaches(tried) {
245
+ const bases = _pluginCacheBases();
246
+ let best = null;
247
+ let bestMtimeMs = -1;
248
+
249
+ for (const base of bases) {
250
+ const cacheDir = path.join(base, 'plugins', 'cache');
251
+ if (!_isDir(cacheDir)) continue;
252
+
253
+ let marketplaces;
254
+ try { marketplaces = readdirSync(cacheDir); } catch { continue; }
255
+
256
+ for (const marketplace of marketplaces) {
257
+ const pluginDir = path.join(cacheDir, marketplace, 'session-orchestrator');
258
+ if (!_isDir(pluginDir)) continue;
259
+
260
+ let versions;
261
+ try { versions = readdirSync(pluginDir); } catch { continue; }
262
+
263
+ for (const version of versions) {
264
+ const candidate = path.join(pluginDir, version);
265
+ // The directory NAME is not proof — a foreign package may sit under a
266
+ // `session-orchestrator/` marketplace folder. The package.json marker is.
267
+ if (!_isPluginRoot(candidate)) continue;
268
+ let mtimeMs;
269
+ try { mtimeMs = statSync(candidate).mtimeMs; } catch { continue; }
270
+ if (mtimeMs > bestMtimeMs) { bestMtimeMs = mtimeMs; best = candidate; }
271
+ }
272
+ }
273
+ }
274
+
275
+ if (!best) {
276
+ const globs = bases.map((base) => path.join(base, 'plugins', 'cache', '*', 'session-orchestrator', '*'));
277
+ tried.push(`plugin caches (${globs.join(', ')}) — no package.json{name:session-orchestrator} found`);
278
+ }
279
+ return best;
280
+ }
281
+
160
282
  // ---------------------------------------------------------------------------
161
283
  // resolvePluginRoot
162
284
  // ---------------------------------------------------------------------------
@@ -164,13 +286,9 @@ function _walkUp(startDir) {
164
286
  /**
165
287
  * Resolve the absolute path to the session-orchestrator plugin directory.
166
288
  *
167
- * Fallback order:
168
- * 1. Trimmed native PLUGIN_ROOT when it is an existing directory
169
- * 2. Compatibility root matching a valid explicit SO_PLATFORM
170
- * 3. Remaining compatibility roots in legacy order
171
- * 4. Walk up from import.meta.url (the location of this file) looking for a
172
- * package.json with name "session-orchestrator"
173
- * 5. Walk up from process.cwd() looking for the same marker
289
+ * Tier order is defined ONCE in this file's top docblock (§ TIER ORDER) and
290
+ * mirrored by `.mcp.json`'s bash bootstrap. Do not restate it here a second
291
+ * copy is what let the two drift.
174
292
  *
175
293
  * @param {string} [platformHint] Optional compatibility hint for wrapper callers
176
294
  * @returns {string} Absolute path to the plugin root
@@ -200,11 +318,17 @@ export function resolvePluginRoot(platformHint) {
200
318
  if (byCwd) return byCwd;
201
319
  tried.push(`walk from cwd (${process.cwd()}) — no package.json{name:session-orchestrator} found`);
202
320
 
321
+ // Level 8: client plugin caches — the marketplace-install case, where no env
322
+ // var is provided and the cwd is outside every checkout (#64).
323
+ const byPluginCache = _scanPluginCaches(tried);
324
+ if (byPluginCache) return byPluginCache;
325
+
203
326
  throw new PluginRootResolutionError(
204
327
  'Could not resolve session-orchestrator plugin root. ' +
205
328
  'Set PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, CODEX_PLUGIN_ROOT, CURSOR_RULES_DIR, or PI_PLUGIN_ROOT ' +
206
- 'to the plugin directory, or ensure a package.json with name "session-orchestrator" exists in an ' +
207
- 'ancestor of the cwd or this script. Attempted: ' + tried.join('; '),
329
+ 'to the plugin directory, ensure a package.json with name "session-orchestrator" exists in an ' +
330
+ 'ancestor of the cwd or this script, or (re)install the plugin through your client\'s marketplace ' +
331
+ 'so a cached copy exists. Attempted: ' + tried.join('; '),
208
332
  tried,
209
333
  );
210
334
  }
@@ -227,6 +227,37 @@ function formatBytes(bytes) {
227
227
  return `${bytes} B`;
228
228
  }
229
229
 
230
+ /**
231
+ * Untracked is not the same as disposable. Three classes of untracked file
232
+ * under .orchestrator/ are load-bearing, so they are removed from the candidate
233
+ * set ENTIRELY rather than merely downgraded: a finding an operator must not
234
+ * act on is noise, and `fixable: true` routes it to the batch-work lane where
235
+ * acting on it is precisely what happens.
236
+ *
237
+ * `.backfilled-*.marker` — the TOCTOU idempotency guards written by
238
+ * scripts/lib/session-close-backfill.mjs (`markerName()`). The file's
239
+ * EXISTENCE is the whole state: delete one and the next backfill pass
240
+ * re-runs for that session id and duplicates its record. Age is the
241
+ * NORMAL condition of a marker whose session closed months ago.
242
+ * `*.jsonl` — append-only telemetry ledgers (audit, autopilot,
243
+ * vault-staleness, repair-candidates). They ARE the history every metric
244
+ * is computed from; mtime says when the ledger last grew, never whether
245
+ * its contents are still needed.
246
+ * `*.log` — append-only diagnostic logs (reconcile.rejected.log and
247
+ * siblings). Same argument as the ledgers.
248
+ *
249
+ * Everything else stays reportable: stray `*.json` scratch, `research/*.md`,
250
+ * abandoned run directories, and the 147 MB of test-run captures this check was
251
+ * built for.
252
+ *
253
+ * @param {string} name basename of the candidate file
254
+ * @returns {boolean}
255
+ */
256
+ function isRetainedArtifact(name) {
257
+ if (name.startsWith('.backfilled-') && name.endsWith('.marker')) return true;
258
+ return name.endsWith('.jsonl') || name.endsWith('.log');
259
+ }
260
+
230
261
  /**
231
262
  * H3 — Aged orchestrator artifacts.
232
263
  *
@@ -245,6 +276,12 @@ function formatBytes(bytes) {
245
276
  * their age is a sign of stability, not decay. Proposing them for
246
277
  * "pruning" is proposing to delete source.
247
278
  *
279
+ * 3. Untracked is not the same as disposable. Three classes are untracked BY
280
+ * DESIGN and load-bearing, and are excluded outright (see
281
+ * `isRetainedArtifact`). Measured in this repo 2026-08-24: 24 of the 25
282
+ * reported candidates were one of those three, so the finding was 96%
283
+ * unactionable while carrying `fixable: true`.
284
+ *
248
285
  * @param {string} repoRoot
249
286
  * @param {number} ageDays
250
287
  * @param {number} now
@@ -298,6 +335,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
298
335
  // walk, not the reportable subset.
299
336
  scanned++;
300
337
  if (tracked.has(full)) continue;
338
+ // Untracked BY DESIGN and load-bearing — never a pruning candidate.
339
+ if (isRetainedArtifact(e.name)) continue;
301
340
  try {
302
341
  const st = statSync(full);
303
342
  if (st.mtimeMs < cutoff) {
@@ -318,14 +357,15 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
318
357
  // (Phase 4), which routes fixable findings to "safe batch work" instead of
319
358
  // the operator Q&A — prose guidance to the coordinator, not an automatic
320
359
  // deletion run; `grep -rn "fixable" scripts/ skills/ hooks/` finds no other
321
- // reader than the `mechanical` count below. With tracked paths excluded the
322
- // claim is now true: every reported path is an untracked artifact.
360
+ // reader than the `mechanical` count below. With tracked paths AND the
361
+ // retained classes excluded the claim is now true: every reported path is an
362
+ // untracked artifact whose deletion loses nothing but disk.
323
363
  return {
324
364
  check: 'stale-artifacts',
325
365
  fixable: true,
326
366
  agedFiles: aged,
327
367
  agedBytes,
328
- message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning`,
368
+ message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning (idempotency markers + ledgers excluded)`,
329
369
  };
330
370
  }
331
371