session-orchestrator 5.12.0 → 5.13.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +162 -4
  11. package/README.md +126 -127
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +1 -1
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +90 -17
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +49 -15
  47. package/docs/session-config-template.md +30 -22
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +139 -49
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -2
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +39 -27
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -69,7 +69,7 @@ import { fileURLToPath } from 'node:url';
69
69
  import { emitEvent, sessionAttribution } from './events.mjs';
70
70
  import { resolveWaveNumber } from './quality-gate-wave.mjs';
71
71
  import { parsePorcelainZ } from './git-porcelain.mjs';
72
- import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
72
+ import { admitSuiteCounts, extractTestCounts, leftoverGroupLine } from './gates/gate-helpers.mjs';
73
73
  import {
74
74
  DEFAULT_GATE_TIMEOUT_MS,
75
75
  buildCommandSignature,
@@ -78,6 +78,7 @@ import {
78
78
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
79
79
  import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
80
80
  import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
81
+ import { resolveOwnLockRoot } from '../../hooks/_lib/hook-root.mjs';
81
82
 
82
83
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
83
84
 
@@ -319,7 +320,8 @@ function resolveCommands(override, repoRoot) {
319
320
  * (`spawnFn`, `killFn`, `isAliveFn`, `sleepFn`, `killGraceMs`, `verifyWaitMs`).
320
321
  * Test seam only; production passes nothing and gets the module defaults.
321
322
  * @returns {Promise<{ exitCode: number, output: string, timedOut: boolean,
322
- * overflow: boolean, killSignals: string[], survivors: number[], pgid: number }>}
323
+ * overflow: boolean, killSignals: string[], survivors: number[]|null, error?: string, pgid: number,
324
+ * leftoverGroup?: {pgid: number, signals: string[], survivors: number[]} }>}
323
325
  */
324
326
  async function runGate(cmd, cwd, opts = {}) {
325
327
  try {
@@ -349,9 +351,16 @@ async function runGate(cmd, cwd, opts = {}) {
349
351
  // A process that outlived SIGKILL (one that `setsid`-ed out of the group) is
350
352
  // SAID OUT LOUD on the channel the operator and the diagnostics bundle both
351
353
  // read. Silence here would be the exact failure the ladder exists to expose.
352
- const output = result.survivors.length > 0
354
+ const survivorOutput = result.survivors === null
355
+ ? `${result.output}\nÜberlebende nicht messbar: ${result.error}`.trim()
356
+ : result.survivors.length > 0
353
357
  ? `${result.output}\ngate: ${result.survivors.length} process(es) survived SIGKILL: ${result.survivors.join(', ')}`.trim()
354
358
  : result.output;
359
+ // #1704: a group still alive after a NORMAL exit was reaped by the ladder.
360
+ // Named here, never a status change (pass/fail stays the exit code's).
361
+ const output = result.leftoverGroup
362
+ ? `${survivorOutput}\n${leftoverGroupLine(result.leftoverGroup)}`.trim()
363
+ : survivorOutput;
355
364
 
356
365
  return {
357
366
  exitCode: result.exitCode,
@@ -360,6 +369,8 @@ async function runGate(cmd, cwd, opts = {}) {
360
369
  overflow: result.overflow,
361
370
  killSignals: result.killSignals,
362
371
  survivors: result.survivors,
372
+ ...(result.survivors === null ? { error: result.error, ok: false } : {}),
373
+ ...(result.leftoverGroup ? { leftoverGroup: result.leftoverGroup } : {}),
363
374
  pgid: result.pgid,
364
375
  };
365
376
  } catch (err) {
@@ -607,16 +618,25 @@ function classifyCurrentSessionOwnership(parsed, ownIds) {
607
618
  * WARN is not de-duplicated; this function runs at most once per fixer dispatch
608
619
  * plus once at abort, and each line marks a distinct decision point.
609
620
  *
621
+ * The file is read beside THIS session's own lock (#1630): after /clear in an
622
+ * entered worktree the lock and `current-session.json` live there (#1605), and
623
+ * after ExitWorktree the gate runs with `repoRoot` on the main checkout, where
624
+ * the file belongs to another session. `resolveOwnLockRoot` follows the host
625
+ * registry's `session_root` pointer — the lookup the hook writer
626
+ * (`post-tool-failure-corrective-context.mjs`) uses — and answers `repoRoot`
627
+ * unchanged when no own lock is found elsewhere.
628
+ *
610
629
  * @param {string} repoRoot
611
630
  * @returns {Array<object>}
612
631
  */
613
632
  function readCorrectiveContext(repoRoot) {
614
633
  try {
615
- const p = join(repoRoot, '.orchestrator', 'current-session.json');
634
+ const ownIds = readOwnSessionIds();
635
+ const ownRoot = resolveOwnLockRoot({ session_id: [...ownIds][0] ?? null, cwd: repoRoot }, repoRoot);
636
+ const p = join(ownRoot, '.orchestrator', 'current-session.json');
616
637
  if (!existsSync(p)) return [];
617
638
  const raw = readFileSync(p, 'utf8');
618
639
  const parsed = JSON.parse(raw);
619
- const ownIds = readOwnSessionIds();
620
640
  const { verdict, fileIds } = classifyCurrentSessionOwnership(parsed, ownIds);
621
641
  if (verdict === 'foreign') {
622
642
  process.stderr.write(
@@ -791,13 +811,15 @@ function coerceMaxRetries(n) {
791
811
  * - `kill_signals` — the signals actually sent, in order (`SIGTERM`, `SIGKILL`).
792
812
  * Copied, because `spawnInGroup` records them at send time into a live array.
793
813
  *
794
- * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null|undefined} run
795
- * @returns {{timed_out: boolean, survivors: number, kill_signals: string[]}}
814
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string}|null|undefined} run
815
+ * @returns {{timed_out: boolean, survivors: number|null, error?: string, kill_signals: string[]}}
796
816
  */
817
+ // Explicit null is an unmeasurable cleanup, never a measured zero.
797
818
  export function gateKillFields(run) {
798
819
  return {
799
820
  timed_out: run?.timedOut === true,
800
- survivors: Array.isArray(run?.survivors) ? run.survivors.length : 0,
821
+ survivors: run?.survivors === null ? null : (Array.isArray(run?.survivors) ? run.survivors.length : 0),
822
+ ...(run?.survivors === null ? { error: run.error, ok: false } : {}),
801
823
  kill_signals: Array.isArray(run?.killSignals) ? [...run.killSignals] : [],
802
824
  };
803
825
  }
@@ -887,7 +909,7 @@ export function gateKillFields(run) {
887
909
  * @param {number} attempts
888
910
  * @param {string|null} gate
889
911
  * @param {{passed: number, failed: number, total: number}|null} [counts]
890
- * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null} [killRun]
912
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string}|null} [killRun]
891
913
  * the run that decided the verdict — the last attempt's failing gate; `null`
892
914
  * on the passing path, where every gate exited 0 and so none was killed
893
915
  * (`spawnInGroup` only starts its kill ladder on timeout or overflow, and
@@ -1036,6 +1058,8 @@ export async function runQualityGateWithRetry(opts) {
1036
1058
  timedOut: result.timedOut,
1037
1059
  killSignals: result.killSignals,
1038
1060
  survivors: result.survivors,
1061
+ ...(result.survivors === null ? { error: result.error, ok: false } : {}),
1062
+ ...(result.leftoverGroup ? { leftoverGroup: result.leftoverGroup } : {}),
1039
1063
  command: cmd,
1040
1064
  attempt,
1041
1065
  };
@@ -41,11 +41,13 @@
41
41
  import { readFileSync, readdirSync } from 'node:fs';
42
42
  import { isAbsolute, join } from 'node:path';
43
43
 
44
+ import { resolveLedgerPath } from '../ledger-path.mjs';
44
45
  import { learningKeyOf } from '../learnings/kebab.mjs';
45
46
  import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
46
47
  import { isRuleExpired, parseGlobsFrontmatter } from '../rule-loader.mjs';
47
48
  import { filterEligible } from './eligibility.mjs';
48
49
  import { isProcessed, loadCandidates as realLoadCandidates } from './idempotency.mjs';
50
+ import { loadVerdictLedger } from './verdict-ledger.mjs';
49
51
 
50
52
  /** Default repo-relative location of the learnings corpus. */
51
53
  export const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
@@ -65,7 +67,7 @@ export function defaultLoadLearnings(repoRoot) {
65
67
  const root = typeof repoRoot === 'string' && repoRoot.length > 0 ? repoRoot : process.cwd();
66
68
  const absPath = isAbsolute(DEFAULT_LEARNINGS_PATH)
67
69
  ? DEFAULT_LEARNINGS_PATH
68
- : join(root, DEFAULT_LEARNINGS_PATH);
70
+ : resolveLedgerPath(DEFAULT_LEARNINGS_PATH, root);
69
71
 
70
72
  let raw;
71
73
  try {
@@ -198,12 +200,15 @@ const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
198
200
  * unparseable `expires-at` is NOT expired; fail-closed would un-materialize the
199
201
  * whole corpus on one typo).
200
202
  *
201
- * KNOWN LIMIT (measured 2026-09-18): this covers only the on-disk half of
202
- * {@link partitionMaterialized} (`sidecarTerminal || onDisk`). 79 of this
203
- * repo's 92 provenance keys are ALSO terminal in the idempotency sidecar, so
204
- * only 13 actually become re-proposable; the sidecar half is left untouched on
205
- * purpose because it carries operator DECLINES. See `docs/rule-authoring.md`
206
- * § Consolidated rules.
203
+ * KNOWN LIMIT (measured 2026-09-18): this covers only the on-disk third of
204
+ * {@link partitionMaterialized} (`sidecarTerminal || ledgerTerminal || onDisk`).
205
+ * 79 of this repo's 92 provenance keys are ALSO terminal in the idempotency
206
+ * sidecar, so only 13 actually become re-proposable; the sidecar and the
207
+ * verdict ledger (#1710) are left untouched on purpose because they carry
208
+ * operator DECLINES — the ledger holds DECISIONS only (`rejected`,
209
+ * `expired-swept`; never the branch-scoped `written` / `already-on-disk`), so
210
+ * an expiring rule's own `written` verdict never pins it there. See
211
+ * `docs/rule-authoring.md` § Consolidated rules.
207
212
  *
208
213
  * Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
209
214
  * `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
@@ -267,20 +272,22 @@ export function defaultReadMaterializedProvenance(repoRoot) {
267
272
  * Split rule-eligible learnings into the ones still awaiting a rule and the ones
268
273
  * already materialized (#484/#1242 dedupe contract). A learning is materialized
269
274
  * when the idempotency sidecar carries a terminal verdict for its `learning_key`
270
- * (CACHE) OR a `.claude/rules/*.md` provenance marker names its key/id
271
- * (AUTHORITATIVE).
275
+ * (CACHE) OR the append-only verdict ledger does (`./verdict-ledger.mjs`, #1710 —
276
+ * survives worktrees and hosts the sidecar does not) OR a `.claude/rules/*.md`
277
+ * provenance marker names its key/id (AUTHORITATIVE).
272
278
  *
273
279
  * Pure: reads nothing, writes nothing, emits nothing — the engine and the
274
280
  * session-start reconcile nudge share it so both judge the same backlog (#1380).
275
281
  *
276
282
  * @param {Array<Record<string, unknown>>} eligible - output of `filterEligible().eligible`.
277
- * @param {{ existingCandidates: import('./idempotency.mjs').ReconcileCandidate[], materialized: { keys: Set<string>, ids: Set<string> } }} sources
283
+ * @param {{ existingCandidates: import('./idempotency.mjs').ReconcileCandidate[], materialized: { keys: Set<string>, ids: Set<string> }, ledgerKeys?: { has(key: string): boolean } }} sources
284
+ * `ledgerKeys` — terminal learning keys of the verdict ledger; absent ⇒ none.
278
285
  * @returns {{
279
286
  * stillEligible: Array<Record<string, unknown>>,
280
- * materializedItems: Array<{ learning: Record<string, unknown>, learningKey: string|null, sidecarTerminal: boolean, onDisk: boolean }>,
287
+ * materializedItems: Array<{ learning: Record<string, unknown>, learningKey: string|null, sidecarTerminal: boolean, ledgerTerminal: boolean, onDisk: boolean }>,
281
288
  * }}
282
289
  */
283
- export function partitionMaterialized(eligible, { existingCandidates, materialized }) {
290
+ export function partitionMaterialized(eligible, { existingCandidates, materialized, ledgerKeys }) {
284
291
  /** @type {Array<Record<string, unknown>>} */
285
292
  const stillEligible = [];
286
293
  const materializedItems = [];
@@ -296,14 +303,16 @@ export function partitionMaterialized(eligible, { existingCandidates, materializ
296
303
 
297
304
  const sidecarTerminal =
298
305
  learningKey !== null && isProcessed({ learning_key: learningKey }, existingCandidates);
306
+ const ledgerTerminal =
307
+ learningKey !== null && !!ledgerKeys && typeof ledgerKeys.has === 'function' && ledgerKeys.has(learningKey);
299
308
  const onDisk =
300
309
  (learningKey !== null && materialized.keys.has(learningKey)) ||
301
310
  (learningId !== null && materialized.ids.has(learningId));
302
311
 
303
- if (!sidecarTerminal && !onDisk) {
312
+ if (!sidecarTerminal && !ledgerTerminal && !onDisk) {
304
313
  stillEligible.push(learning);
305
314
  } else {
306
- materializedItems.push({ learning, learningKey, sidecarTerminal, onDisk });
315
+ materializedItems.push({ learning, learningKey, sidecarTerminal, ledgerTerminal, onDisk });
307
316
  }
308
317
  }
309
318
  return { stillEligible, materializedItems };
@@ -312,7 +321,8 @@ export function partitionMaterialized(eligible, { existingCandidates, materializ
312
321
  /**
313
322
  * Count the reconcile BACKLOG the way a real `/reconcile` run would see it:
314
323
  * rule-eligible learnings (expiry-gated by `now`) minus those already
315
- * materialized in the sidecar or under `.claude/rules/`. This is the quantity
324
+ * materialized in the sidecar, the verdict ledger (#1710) or under
325
+ * `.claude/rules/`. This is the quantity
316
326
  * `/reconcile` can actually drive to zero — the session-start nudge judges on it
317
327
  * (#1380; HR-101: a probe that counts already-materialized learnings can never
318
328
  * go green).
@@ -332,6 +342,8 @@ export function partitionMaterialized(eligible, { existingCandidates, materializ
332
342
  * already-loaded sidecar records (DI seam for a caller that read the store
333
343
  * itself — the session-start nudge does, for the last-run timestamp). Absent
334
344
  * ⇒ this function reads the store via {@link defaultLoadCandidatesForDedupe}.
345
+ * @param {{ has(key: string): boolean }} [params.ledgerKeys] - terminal keys of the verdict
346
+ * ledger (DI seam). Absent ⇒ read via `loadVerdictLedger(repoRoot)` (repoRoot-gated).
335
347
  * @param {Date|number} [params.now] - clock for the expiry gate (defaults to `Date.now()`).
336
348
  * @param {number} [params.minInsightChars] - forwarded to `filterEligible`.
337
349
  * @returns {{ eligible: number, alreadyMaterialized: number, backlog: number }}
@@ -340,6 +352,7 @@ export function countReconcileBacklog({
340
352
  repoRoot,
341
353
  learnings,
342
354
  existingCandidates,
355
+ ledgerKeys,
343
356
  now,
344
357
  minInsightChars,
345
358
  } = {}) {
@@ -356,6 +369,7 @@ export function countReconcileBacklog({
356
369
  ? existingCandidates
357
370
  : defaultLoadCandidatesForDedupe(repoRoot).records,
358
371
  materialized: defaultReadMaterializedProvenance(repoRoot),
372
+ ledgerKeys: ledgerKeys ?? loadVerdictLedger(repoRoot).terminalKeys,
359
373
  });
360
374
  return {
361
375
  eligible: eligible.length,
@@ -125,6 +125,7 @@ import {
125
125
  makeCandidateId,
126
126
  mergeCandidates as realMergeCandidates,
127
127
  } from './idempotency.mjs';
128
+ import { appendRun, loadVerdictLedger } from './verdict-ledger.mjs';
128
129
 
129
130
  /**
130
131
  * Re-exported from the LEAF module `./backlog.mjs` (which now owns the
@@ -354,6 +355,10 @@ export function resolveEffectiveTargets({ targets, baselineRoot } = {}) {
354
355
  * @param {(repoRoot?: string) => { keys: Set<string>, ids: Set<string> }} [opts.readMaterializedProvenance]
355
356
  * override the `.claude/rules/` provenance scan used by the same dedupe step —
356
357
  * defaults to {@link defaultReadMaterializedProvenance} (same repoRoot gate).
358
+ * @param {(repoRoot?: string) => { terminalKeys: { has(key: string): boolean } }} [opts.loadVerdictLedger]
359
+ * override the append-only verdict-ledger read (#1710) used by the same dedupe step —
360
+ * defaults to `loadVerdictLedger` (same repoRoot gate). After a successful non-dryRun
361
+ * merge the engine appends one run record to that ledger (explicit repoRoot only).
357
362
  * @param {boolean} [opts.dryRun] - when true, compute proposals but SKIP the merge entirely.
358
363
  * @returns {Promise<ReconcileResult>}
359
364
  */
@@ -381,6 +386,8 @@ async function runReconcileInner(
381
386
  typeof opts.readMaterializedProvenance === 'function'
382
387
  ? opts.readMaterializedProvenance
383
388
  : defaultReadMaterializedProvenance;
389
+ const readVerdictLedger =
390
+ typeof opts.loadVerdictLedger === 'function' ? opts.loadVerdictLedger : loadVerdictLedger;
384
391
  // Volume brake (#900 D) — always active; a missing/invalid override falls
385
392
  // back to the same default the Session Config parser uses.
386
393
  const maxProposalsPerRun =
@@ -453,18 +460,24 @@ async function runReconcileInner(
453
460
  // shape" test in `tests/lib/reconcile/engine.test.mjs`.
454
461
  const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
455
462
  const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
463
+ // #1710 — the append-only verdict ledger carries terminal verdicts into a
464
+ // fresh worktree / another host, where the checkout-local sidecar is empty.
465
+ const ledgerKeys = readVerdictLedger(repoRoot)?.terminalKeys;
456
466
 
457
467
  const { stillEligible, materializedItems } = partitionMaterialized(eligible, {
458
468
  existingCandidates,
459
469
  materialized,
470
+ ledgerKeys,
460
471
  });
461
472
  const alreadyMaterialized = materializedItems.length;
462
473
 
463
- for (const { learning, learningKey, sidecarTerminal, onDisk } of materializedItems) {
474
+ for (const { learning, learningKey, sidecarTerminal, ledgerTerminal, onDisk } of materializedItems) {
464
475
  const type = learningType(learning);
465
476
  const reason = sidecarTerminal
466
477
  ? 'already processed — the idempotency sidecar already carries a terminal verdict for this learning-key'
467
- : 'already materialized — a .claude/rules/ file already carries this learning-key/learning-id';
478
+ : ledgerTerminal
479
+ ? 'already processed — the reconcile verdict ledger already carries a terminal verdict for this learning-key'
480
+ : 'already materialized — a .claude/rules/ file already carries this learning-key/learning-id';
468
481
  rejected.push({ learningKey, type, reason, status: 'rejected' });
469
482
 
470
483
  // Freshly discovered on-disk materialization (not yet reflected in the
@@ -632,6 +645,17 @@ async function runReconcileInner(
632
645
  if (mergeResult && Number.isFinite(mergeResult.skipped)) {
633
646
  skipped = Number(mergeResult.skipped);
634
647
  }
648
+ // #1710 — record the run itself, so run recency survives a checkout
649
+ // without the sidecar, with the learnings count it saw (the nudge's
650
+ // "new since last run" baseline). Explicit repoRoot only (never cwd).
651
+ if (written && typeof repoRoot === 'string' && repoRoot.length > 0) {
652
+ const run = appendRun(repoRoot, createdAt, totalLearnings);
653
+ if (run.ok !== true) {
654
+ process.stderr.write(
655
+ `⚠ reconcile: run record not appended to the verdict ledger (${run.error ?? 'unknown error'})\n`,
656
+ );
657
+ }
658
+ }
635
659
  } catch {
636
660
  // Merge failure is non-fatal; proposals still returned, written stays
637
661
  // false and `skipped` stays absent (the store was never inspected).
@@ -61,6 +61,7 @@ import { readFileSync } from 'node:fs';
61
61
  import { isAbsolute, join } from 'node:path';
62
62
  import { createHash } from 'node:crypto';
63
63
  import { atomicWriteWithBackup } from '../io.mjs';
64
+ import { appendVerdicts, isDecisionOutcome, loadVerdictLedger } from './verdict-ledger.mjs';
64
65
 
65
66
  /** Default repo-relative location of the reconcile-candidate work-queue. */
66
67
  export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
@@ -317,11 +318,22 @@ export function isProcessed(candidate, existing) {
317
318
  * (malformed JSON, or a record missing `learning_key`/`created_at`). Because the
318
319
  * store is rewritten in full, those lines are DROPPED from disk by this call —
319
320
  * the count is what makes that loss attributable instead of silent.
321
+ *
322
+ * Verdict ledger (#1710): after a successful store write, and ONLY when
323
+ * `repoRoot` was passed explicitly, every terminal DECISION of the merged store
324
+ * (`isDecisionOutcome` — not the branch-scoped `written` / `already-on-disk` /
325
+ * absent outcome, see `BRANCH_SCOPED_OUTCOMES`) whose `learning_key` the
326
+ * append-only ledger (`./verdict-ledger.mjs`) does not yet hold is appended
327
+ * there — new stamps AND older sidecar verdicts (a one-time backfill). The
328
+ * ledger outlives the gitignored, checkout-local sidecar, so a fresh worktree
329
+ * keeps an operator decline. A ledger failure never turns a successful store
330
+ * write into a failure; it is reported in the additive `ledger` field (absent
331
+ * when the ledger was not consulted) and as ONE stderr WARN.
320
332
  * @param {Object} [params]
321
333
  * @param {ReconcileCandidate[]} [params.candidates] - newly minted candidates to merge.
322
334
  * @param {string} [params.repoRoot]
323
335
  * @param {string} [params.storePath]
324
- * @returns {{ merged: ReconcileCandidate[], written: boolean, skipped: number }}
336
+ * @returns {{ merged: ReconcileCandidate[], written: boolean, skipped: number, ledger?: { ok: boolean, appended: number, error?: string } }}
325
337
  */
326
338
  export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
327
339
  const absPath = resolveStorePath(repoRoot, storePath);
@@ -353,7 +365,28 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
353
365
  );
354
366
 
355
367
  const result = writeStore(absPath, merged);
356
- return { merged, written: result.ok === true, skipped };
368
+ const written = result.ok === true;
369
+ if (!written || typeof repoRoot !== 'string' || repoRoot.length === 0) {
370
+ return { merged, written, skipped };
371
+ }
372
+
373
+ const ledger = loadVerdictLedger(repoRoot);
374
+ if (ledger.readError) {
375
+ // Unreadable ledger: appending blind would duplicate every verdict.
376
+ process.stderr.write('⚠ mergeCandidates: verdict ledger unreadable — no verdicts mirrored this merge\n');
377
+ return { merged, written, skipped, ledger: { ok: false, appended: 0, error: 'ledger unreadable' } };
378
+ }
379
+ const missing = merged.filter(
380
+ (rec) => isTerminal(rec) && isDecisionOutcome(rec.outcome) && !ledger.terminalKeys.has(rec.learning_key),
381
+ );
382
+ const appended = appendVerdicts(repoRoot, missing);
383
+ if (appended.ok !== true) {
384
+ process.stderr.write(
385
+ `⚠ mergeCandidates: verdict ledger append failed (${appended.error ?? 'unknown error'}) — ` +
386
+ `${missing.length} verdict(s) not mirrored\n`,
387
+ );
388
+ }
389
+ return { merged, written, skipped, ledger: appended };
357
390
  }
358
391
 
359
392
  /**
@@ -376,7 +409,10 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
376
409
  * `mergeCandidates`'s own dedupe rule still applies underneath this call: if
377
410
  * the existing record is ALREADY terminal (a prior `processed_at`), the merge
378
411
  * keeps the existing verdict and this call is a no-op on disk — a terminal
379
- * verdict is never regressed, even by a second stamp attempt.
412
+ * verdict is never regressed, even by a second stamp attempt. One exception
413
+ * (#1710): a DECISION (`isDecisionOutcome`) stamped over a kept `written` /
414
+ * `already-on-disk` record is still appended to the verdict ledger (explicit
415
+ * `repoRoot` only), so a dropped or swept rule stays dropped in other checkouts.
380
416
  *
381
417
  * Never throws (delegates to the never-throwing `loadCandidates`/
382
418
  * `mergeCandidates`); on write failure returns `{ written: false, ... }`.
@@ -460,6 +496,32 @@ export function markCandidateProcessed({
460
496
  // the caller a verdict that is nowhere on disk. `merged` IS the array that was
461
497
  // just written, so reading the record back out of it is the honest answer.
462
498
  const persisted = result.merged.find((rec) => rec && rec.learning_key === learningKey);
499
+
500
+ // A DECISION stamped over a kept branch-scoped terminal record (`written` /
501
+ // `already-on-disk`) never reaches the ledger through `mergeCandidates`: the
502
+ // store keeps the old outcome, and only decisions are mirrored. That is the
503
+ // rule-drop recipe and the expiry sweep's delete stamp — append it here so the
504
+ // drop holds in other checkouts too (#1710).
505
+ if (
506
+ result.written === true &&
507
+ typeof repoRoot === 'string' &&
508
+ repoRoot.length > 0 &&
509
+ isDecisionOutcome(outcome) &&
510
+ persisted &&
511
+ !isDecisionOutcome(persisted.outcome)
512
+ ) {
513
+ const ledger = loadVerdictLedger(repoRoot);
514
+ if (!ledger.readError && !ledger.terminalKeys.has(learningKey)) {
515
+ const appended = appendVerdicts(repoRoot, [{ learning_key: learningKey, outcome, processed_at: stampAt }]);
516
+ if (appended.ok !== true) {
517
+ process.stderr.write(
518
+ `⚠ markCandidateProcessed: verdict ledger append failed (${appended.error ?? 'unknown error'}) — ` +
519
+ `${outcome} for ${learningKey} not mirrored\n`,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
463
525
  return {
464
526
  written: result.written === true,
465
527
  stamped: persisted ?? stamped,
@@ -141,10 +141,13 @@
141
141
  */
142
142
 
143
143
  import { lstatSync, readFileSync, unlinkSync } from 'node:fs';
144
+ import { readFile } from 'node:fs/promises';
144
145
  import { join, resolve, sep } from 'node:path';
145
146
 
146
147
  import { atomicWriteWithBackup } from '../io.mjs';
147
- import { readLearnings } from '../learnings/io.mjs';
148
+ import { resolveLedgerPath } from '../ledger-path.mjs';
149
+ import { parseLearningsText } from '../learnings/io.mjs';
150
+ import { archivePathFor } from '../learnings/store-role.mjs';
148
151
  import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
149
152
  import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
150
153
  import { markCandidateProcessed } from './idempotency.mjs';
@@ -371,26 +374,57 @@ export function parseConsolidatedRule(content) {
371
374
  }
372
375
 
373
376
  /**
374
- * Read `learnings.jsonl` into `id -> expires_at`, counting malformed lines.
377
+ * Read the archive and live `learnings.jsonl` into `id -> expires_at`,
378
+ * counting malformed lines separately for each source.
375
379
  *
376
380
  * Malformed lines are COUNTED, never silently dropped: a leniently-skipping
377
381
  * JSONL parser turns a partial read into a clean verdict, and this sweep
378
382
  * DELETES prose on the strength of that verdict.
379
383
  *
384
+ * Boundary (BV-004, #1685): the index reads the live store and archive, never the
385
+ * sibling `learnings-inbox.jsonl` (#1516). An inbox-only id resolves as absent:
386
+ * consolidated pairs stay unresolved/kept, a single-entry file falls back to
387
+ * its own `expires-at`. Including the inbox would not be purely conservative —
388
+ * a resolvable past `expires_at` would let `rewriteContent` remove a pair that
389
+ * is kept today. Ceiling: an inbox record's later `expires_at` cannot delay a
390
+ * single-entry delete. Revisit trigger: the first real inbox host, or a
391
+ * generated rule citing an inbox-only learning-id.
392
+ * The archive is admissible because each learning's own `expires_at` carries
393
+ * the expiry evidence; live records override archived records with the same id.
394
+ * Superseded/merged tombstones stay excluded because their content lives in a
395
+ * winner; revisit pointer resolution if a rule section stays permanently
396
+ * unresolved because it cites only a tombstone.
397
+ *
380
398
  * @param {string} absPath
381
- * @returns {Promise<{expiryById: Map<string, string>, malformedLines: number}>}
399
+ * @returns {Promise<{expiryById: Map<string, string>, malformedLines: number, archiveMalformedLines: number}>}
382
400
  */
383
401
  async function loadExpiryIndex(absPath) {
384
402
  const expiryById = new Map();
385
- const { entries, malformed } = await readLearnings(absPath);
386
- for (const entry of entries) {
403
+ // Unlike the shared tolerant reader, a sweep needs evidence that the store
404
+ // was actually read: an absent store must not become an empty expiry index.
405
+ const { entries, malformed } = parseLearningsText(await readFile(absPath, 'utf8'));
406
+ let archiveText = '';
407
+ try {
408
+ archiveText = await readFile(archivePathFor(absPath), 'utf8');
409
+ } catch (err) {
410
+ if (err?.code !== 'ENOENT') throw err;
411
+ }
412
+ const archive = parseLearningsText(archiveText);
413
+ const archivedLearnings = archive.entries.filter((entry) =>
414
+ !Object.hasOwn(entry, '_superseded_by') &&
415
+ !Object.hasOwn(entry, '_merged_into') &&
416
+ entry._archive_reason !== 'superseded' &&
417
+ entry._archive_reason !== 'merged',
418
+ );
419
+ // Overlay the live store last: a reactivated learning may have a later expiry.
420
+ for (const entry of [...archivedLearnings, ...entries]) {
387
421
  const id = entry?.id;
388
422
  const expiresAt = entry?.expires_at;
389
423
  if (typeof id === 'string' && id.length > 0 && typeof expiresAt === 'string') {
390
424
  expiryById.set(id, expiresAt);
391
425
  }
392
426
  }
393
- return { expiryById, malformedLines: malformed.length };
427
+ return { expiryById, malformedLines: malformed.length, archiveMalformedLines: archive.malformed.length };
394
428
  }
395
429
 
396
430
  /** `now` as epoch-ms, accepting a Date, a number, an ISO string, or nothing. */
@@ -569,7 +603,7 @@ function keepOrRaisePlan(base, parsed, advisory, raiseTo) {
569
603
  * swept. Default 0: unlike the learnings sweep, nothing re-stamps a rule
570
604
  * file's `expires-at`, so there is no re-stamp window to protect.
571
605
  * @param {string} [opts.learningsPath] override the learnings store
572
- * @returns {Promise<{plans: Array<object>, skipped: Array<{file: string, reason: string}>, malformedLines: number, ok: boolean}>}
606
+ * @returns {Promise<{plans: Array<object>, skipped: Array<{file: string, reason: string}>, malformedLines: number, archiveMalformedLines: number, ok: boolean}>}
573
607
  */
574
608
  export async function planRuleExpirySweep(opts = {}) {
575
609
  const { repoRoot, now, graceDays = 0, learningsPath } = opts;
@@ -582,11 +616,18 @@ export async function planRuleExpirySweep(opts = {}) {
582
616
 
583
617
  const nowMs = toEpochMs(now);
584
618
  const cutoffMs = nowMs - graceDays * MS_PER_DAY;
585
- const { expiryById, malformedLines } = await loadExpiryIndex(
586
- join(repoRoot, learningsPath ?? DEFAULT_LEARNINGS_PATH),
587
- );
588
-
589
619
  const listed = listMachineGeneratedRules({ repoRoot });
620
+ // Ohne generierte Regeln gibt es nichts zu pruefen: der Bestand wird dann
621
+ // nicht gelesen, damit ein Repo ohne learnings.jsonl nicht mit ENOENT endet
622
+ // (Housekeeping Schritt 3 ruft die CLI repo-unabhaengig auf).
623
+ const { expiryById, malformedLines, archiveMalformedLines } =
624
+ listed.rules.length === 0
625
+ ? { expiryById: new Map(), malformedLines: 0, archiveMalformedLines: 0 }
626
+ : await loadExpiryIndex(
627
+ // #1722: the default store (and its archive beside it) follows the
628
+ // ledger root; an explicit learningsPath stays repo-relative as before.
629
+ learningsPath ? join(repoRoot, learningsPath) : resolveLedgerPath(DEFAULT_LEARNINGS_PATH, repoRoot),
630
+ );
590
631
  const rulesDir = join(repoRoot, '.claude', 'rules');
591
632
  /** @type {Array<object>} */
592
633
  const plans = [];
@@ -807,7 +848,7 @@ export async function planRuleExpirySweep(opts = {}) {
807
848
  });
808
849
  }
809
850
 
810
- return { plans, skipped, malformedLines, ok: listed.ok };
851
+ return { plans, skipped, malformedLines, archiveMalformedLines, ok: listed.ok };
811
852
  }
812
853
 
813
854
  /**