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
@@ -261,8 +261,9 @@ export function reconcileIssueBudget({
261
261
  * Emit the reconciliation record to the repo's event ledger.
262
262
  *
263
263
  * Follows `_emitEvaluated` in `scripts/lib/express-path.mjs`: `events.mjs` is
264
- * imported LAZILY (a static import would pull `platform.mjs`, which walks the
265
- * filesystem at module load, into every consumer of this module), `repoRoot` is
264
+ * imported LAZILY (it is not needed by the verdict path; `platform.mjs` is
265
+ * already reached statically through `issue-budget.mjs`, which maps the
266
+ * counter-file root through it since #1729), `repoRoot` is
266
267
  * passed EXPLICITLY and a missing one SKIPS the emit rather than falling
267
268
  * through to the ambient `SO_PROJECT_DIR` — writing a synthetic record into the
268
269
  * operator's real fleet ledger is unrecoverable, a skipped record is not — and
@@ -33,10 +33,11 @@ import { homedir } from 'node:os';
33
33
  import path from 'node:path';
34
34
 
35
35
  import { writeJsonAtomicSync } from './io.mjs';
36
+ import { resolveEventsLedgerRoot } from './platform.mjs';
36
37
  import { resolveInstructionFile } from './common.mjs';
37
38
  import { _parseIssueBudget } from './config/issue-budget.mjs';
38
39
  import { parseStateMd } from './state-md/yaml-parser.mjs';
39
- import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
40
+ import { resolveStateMdWriteTarget } from './state-md/frontmatter-mutators.mjs';
40
41
 
41
42
  /**
42
43
  * Legacy single-slot counter file, relative to the repo root.
@@ -192,8 +193,11 @@ export function resolveMaxPerSession(cfg, sessionType) {
192
193
  */
193
194
  export function readSessionTypeFromStateMd(repoRoot) {
194
195
  try {
195
- const filePath = resolveStateMdPath(repoRoot);
196
- if (!existsSync(filePath)) return null;
196
+ // #1636: the write side's ownership rule — another platform's STATE.md not
197
+ // proven to be this process's must not set THIS session's budget; it counts
198
+ // as absent (default budget). No warn line: this runs on every Bash call.
199
+ const { path: filePath, rejected } = resolveStateMdWriteTarget(repoRoot);
200
+ if (rejected || !existsSync(filePath)) return null;
197
201
  const parsed = parseStateMd(readFileSync(filePath, 'utf8'));
198
202
  const value = parsed?.frontmatter?.['session-type'];
199
203
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
@@ -251,7 +255,27 @@ export function loadIssueBudgetConfig(repoRoot) {
251
255
  * @returns {string}
252
256
  */
253
257
  export function budgetStatePath(repoRoot, sessionId = null) {
254
- return path.join(repoRoot, budgetStateRel(sessionId));
258
+ return path.join(budgetStateRoot(repoRoot), budgetStateRel(sessionId));
259
+ }
260
+
261
+ /**
262
+ * Root directory the runtime counter files live under (#1729). The PreToolUse
263
+ * hook charges at `CLAUDE_PROJECT_DIR` (usually the MAIN checkout) while
264
+ * session-end reconciles and reaps with the linked-worktree root; joining the
265
+ * raw root made the two sides open different files and report a false
266
+ * `no-ledger`. Mapping a linked worktree to its main checkout (the same
267
+ * resolver the event ledger uses) makes every reader and writer agree. Any
268
+ * other root (plain checkout, non-git dir, submodule) is returned resolved but
269
+ * otherwise unchanged. A non-string or blank root is passed through untouched
270
+ * so `path.join` keeps its previous behaviour instead of falling back to the
271
+ * ambient project dir.
272
+ *
273
+ * @param {string} repoRoot
274
+ * @returns {string}
275
+ */
276
+ function budgetStateRoot(repoRoot) {
277
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return repoRoot;
278
+ return resolveEventsLedgerRoot(repoRoot);
255
279
  }
256
280
 
257
281
  /**
@@ -360,7 +384,9 @@ export function readBudgetState(repoRoot, sessionId) {
360
384
  // session has a file of its own.
361
385
  if (existsSync(ownFile)) return fresh;
362
386
 
363
- return _readStateFile(path.join(repoRoot, BUDGET_STATE_REL), accountingSessionId) ?? fresh;
387
+ return (
388
+ _readStateFile(path.join(budgetStateRoot(repoRoot), BUDGET_STATE_REL), accountingSessionId) ?? fresh
389
+ );
364
390
  }
365
391
 
366
392
  /**
@@ -882,11 +908,14 @@ export function reapStaleBudgetFiles({
882
908
  const removed = [];
883
909
  const kept = [];
884
910
  try {
885
- const dir = path.join(repoRoot, BUDGET_STATE_DIR_REL);
911
+ // Directory and own file from ONE mapped root (#1729): mapping only one of
912
+ // them would make the `file === ownFile` protection below never match.
913
+ const stateRoot = budgetStateRoot(repoRoot);
914
+ const dir = path.join(stateRoot, BUDGET_STATE_DIR_REL);
886
915
  const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
887
916
  const ownFile =
888
917
  typeof sessionId === 'string' && sessionId.length > 0
889
- ? budgetStatePath(repoRoot, sessionId)
918
+ ? path.join(stateRoot, budgetStateRel(sessionId))
890
919
  : null;
891
920
 
892
921
  let names;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Explicit, private-audience candidate artifacts are advisory lookup hints.
3
+ * Only current local JSONL records enter the selector; artifact paths, scores
4
+ * and text never become records, executable commands or additional authority.
5
+ */
6
+ import { constants } from 'node:fs';
7
+ import { open } from 'node:fs/promises';
8
+ import { TextDecoder } from 'node:util';
9
+ import { z } from 'zod';
10
+
11
+ import { parseLearningsText } from './io.mjs';
12
+ import { digestMultiBufferSha256 } from '../crypto-digest-utils.mjs';
13
+
14
+ // At most 40 hints in a small local artifact. Revisit the byte ceiling only if
15
+ // a valid 40-candidate producer payload demonstrably exceeds it.
16
+ export const EVIDENCE_ARTIFACT_MAX_BYTES = 128 * 1024;
17
+
18
+ const hash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
19
+ const repo = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/);
20
+ const evidenceType = z.enum(['learning', 'skill', 'adr']);
21
+ const retrievalMode = z.enum(['bm25', 'eg2', 'rrf']);
22
+ const unique = (values) => new Set(values).size === values.length;
23
+ const candidate = z.object({
24
+ evidence_type: evidenceType,
25
+ source_repo: repo,
26
+ source_record_id: z.string().min(1).max(200).optional(),
27
+ source_generation: hash.optional(),
28
+ source_path: z.string().min(1).regex(/^(?!\/)(?![A-Za-z]:)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\\).+$/),
29
+ source_sha256: hash,
30
+ source_revision: z.string().min(1).max(64),
31
+ line_start: z.number().int().min(1),
32
+ line_end: z.number().int().min(1),
33
+ index_generation: hash,
34
+ modelspace: hash,
35
+ rank: z.number().int().min(1).max(40),
36
+ }).strict().refine((value) => value.evidence_type === 'learning'
37
+ ? value.source_record_id !== undefined && value.source_generation !== undefined
38
+ : value.source_record_id === undefined && value.source_generation === undefined);
39
+
40
+ /** Local boundary for urn:local-evidence:candidate-artifact:1. */
41
+ export const evidenceCandidateSchema = z.object({
42
+ schema_version: z.literal(1),
43
+ status: z.enum(['ok', 'empty', 'disabled', 'stale', 'unavailable', 'unreadable', 'corrupt', 'modelspace_mismatch', 'error']),
44
+ query: z.object({
45
+ text: z.string().min(1).max(4000),
46
+ source_repos: z.array(repo).min(1).refine(unique),
47
+ audience: z.literal('private'),
48
+ evidence_types: z.array(evidenceType).min(1).refine(unique),
49
+ }).strict(),
50
+ freshness: z.object({
51
+ mode: z.literal('live_sources'),
52
+ checked_at: z.string().datetime({ offset: true }),
53
+ }).strict(),
54
+ generation: hash.nullable(),
55
+ source_generations: z.record(repo, hash),
56
+ retrieval: z.object({
57
+ requested: retrievalMode,
58
+ effective: retrievalMode,
59
+ fallback_reason: z.enum(['semantic_unconfigured', 'semantic_unavailable', 'semantic_failed', 'modelspace_mismatch', 'index_lexical_only']).optional(),
60
+ }).strict(),
61
+ candidates: z.array(candidate).max(40),
62
+ }).strict().refine((value) => value.status === 'ok' || value.candidates.length === 0);
63
+
64
+ /** Bounded regular-file read; a supplied FIFO must not hang a dispatch. */
65
+ async function readArtifact(filePath) {
66
+ const handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
67
+ try {
68
+ const info = await handle.stat();
69
+ if (!info.isFile() || info.size > EVIDENCE_ARTIFACT_MAX_BYTES) throw new Error('invalid-artifact-file');
70
+ const buffer = Buffer.alloc(EVIDENCE_ARTIFACT_MAX_BYTES + 1);
71
+ let size = 0;
72
+ while (size < buffer.length) {
73
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
74
+ if (bytesRead === 0) break;
75
+ size += bytesRead;
76
+ }
77
+ if (size > EVIDENCE_ARTIFACT_MAX_BYTES) throw new Error('artifact-too-large');
78
+ return JSON.parse(buffer.subarray(0, size).toString('utf8'));
79
+ } finally {
80
+ await handle.close();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolve hints against the explicitly supplied current repo's canonical store.
86
+ * Missing/stale/corrupt inputs return no additions, preserving the baseline.
87
+ * Diagnostics contain only statuses/counts, never artifact paths or query text.
88
+ */
89
+ export async function readEvidenceCandidates({ artifactPath, sourceRepo, query, sourceBytes }) {
90
+ const result = (status, reason, entries = [], dropped = 0, retrieval) => ({
91
+ entries,
92
+ diagnostics: { status, ...(reason ? { reason } : {}), accepted: entries.length, dropped,
93
+ ...(retrieval ? { retrieval } : {}) },
94
+ });
95
+ if (!repo.safeParse(sourceRepo).success || typeof query !== 'string' || !query.trim() ||
96
+ query.length > 4000 || typeof artifactPath !== 'string' || !artifactPath) {
97
+ return result('corrupt', 'invalid_request');
98
+ }
99
+ let artifact;
100
+ try {
101
+ const parsed = evidenceCandidateSchema.safeParse(await readArtifact(artifactPath));
102
+ if (!parsed.success) return result('corrupt', 'invalid_artifact');
103
+ artifact = parsed.data;
104
+ } catch (error) {
105
+ if (error instanceof SyntaxError) return result('corrupt', 'invalid_artifact');
106
+ return result(error?.code === 'ENOENT' ? 'unavailable' : 'unreadable', 'artifact_read_failed');
107
+ }
108
+ if (artifact.query.text !== query || artifact.query.source_repos.length !== 1 ||
109
+ artifact.query.source_repos[0] !== sourceRepo) return result('corrupt', 'request_mismatch');
110
+ if (artifact.status !== 'ok') return result(artifact.status, 'provider_status', [], 0, artifact.retrieval);
111
+ if (artifact.generation === null) return result('corrupt', 'missing_generation');
112
+
113
+ // One source read supplies BOTH the baseline pool and these original records.
114
+ // A missing store is distinct from an existing, valid empty JSONL snapshot.
115
+ if (!Buffer.isBuffer(sourceBytes)) return result('unavailable', 'source_read_failed');
116
+ let sourceText;
117
+ try {
118
+ // ignoreBOM retains the BOM in the decoded text; the canonical parser
119
+ // removes it only from the first record, never from the generation hash.
120
+ sourceText = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(sourceBytes);
121
+ } catch {
122
+ return result('corrupt', 'source_utf8_invalid');
123
+ }
124
+ const snapshot = {
125
+ ...parseLearningsText(sourceText),
126
+ generation: `sha256:${digestMultiBufferSha256([sourceBytes])}`,
127
+ };
128
+ if (artifact.source_generations[sourceRepo] !== snapshot.generation) {
129
+ return result('stale', 'source_generation_mismatch');
130
+ }
131
+
132
+ const byId = new Map();
133
+ for (const entry of snapshot.entries) {
134
+ if (typeof entry.id !== 'string') continue;
135
+ // An ambiguous duplicate ID cannot identify one original record.
136
+ byId.set(entry.id, byId.has(entry.id) ? null : entry);
137
+ }
138
+ const entries = [];
139
+ const seen = new Set();
140
+ let dropped = 0;
141
+ for (const hint of artifact.candidates) {
142
+ const entry = byId.get(hint.source_record_id);
143
+ if (hint.evidence_type !== 'learning' || !artifact.query.evidence_types.includes('learning') ||
144
+ hint.source_repo !== sourceRepo || hint.source_generation !== snapshot.generation ||
145
+ hint.source_sha256 !== snapshot.generation || hint.index_generation !== artifact.generation ||
146
+ hint.line_end < hint.line_start || !entry || seen.has(hint.source_record_id)) {
147
+ dropped++;
148
+ continue;
149
+ }
150
+ seen.add(hint.source_record_id);
151
+ entries.push(entry);
152
+ }
153
+ return result('ok', undefined, entries, dropped, artifact.retrieval);
154
+ }
@@ -70,8 +70,15 @@
70
70
  import { constants as fsConstants, existsSync } from 'node:fs';
71
71
  import { mkdir, appendFile, copyFile } from 'node:fs/promises';
72
72
  import path from 'node:path';
73
- import { readLearnings, readLearningsSnapshot, rewriteLearnings, withLearningsLock } from './io.mjs';
73
+ import {
74
+ appendInboxReceipts,
75
+ readLearnings,
76
+ readLearningsSnapshot,
77
+ rewriteLearnings,
78
+ withLearningsLock,
79
+ } from './io.mjs';
74
80
  import { redactHomeDir } from '../common.mjs';
81
+ import { assertStoreRoleUsable, isCanonicalStore, resolveStoreRole } from './store-role.mjs';
75
82
 
76
83
  /**
77
84
  * Thrown by {@link pruneLearnings} when `expectedGeneration` no longer matches
@@ -216,6 +223,26 @@ function reconcileKey(entry) {
216
223
  */
217
224
  export const ARCHIVE_REASONS = Object.freeze(['expired', 'pruned', 'superseded', 'merged']);
218
225
 
226
+ /**
227
+ * Value of the additive `skipped` result field when an APPLY ran on a host
228
+ * whose learnings-store role is `inbox` (#1516).
229
+ */
230
+ export const SKIPPED_STORE_ROLE_INBOX = 'store-role-inbox';
231
+
232
+ /**
233
+ * True when an apply against `filePath` must not archive or rewrite (#1516):
234
+ * the canonical store on an `inbox`-role host. There `rewriteLearnings()`
235
+ * never touches the live store, so an archive append would record learnings
236
+ * that are still live. Dry runs are unaffected — they write nothing and keep
237
+ * reporting what a writer host would do.
238
+ *
239
+ * @param {unknown} filePath
240
+ * @returns {boolean}
241
+ */
242
+ function isInboxRoleStore(filePath) {
243
+ return typeof filePath === 'string' && isCanonicalStore(filePath) && resolveStoreRole().role === 'inbox';
244
+ }
245
+
219
246
  /** Reason -> the tombstone field that reason MUST carry. */
220
247
  const TOMBSTONE_FIELD = Object.freeze({
221
248
  superseded: '_superseded_by',
@@ -360,7 +387,7 @@ function resolveNowMs(now) {
360
387
  * @param {number} opts.nowMs
361
388
  * @param {boolean} opts.dryRun
362
389
  * @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>,
363
- * malformedField: {malformed?: number}}>}
390
+ * malformedField: {malformed?: number}, skipped?: string}>}
364
391
  */
365
392
  async function archiveThenRewrite({
366
393
  filePath,
@@ -372,6 +399,21 @@ async function archiveThenRewrite({
372
399
  nowMs,
373
400
  dryRun,
374
401
  }) {
402
+ if (!dryRun && isInboxRoleStore(filePath)) {
403
+ // Inbox role (#1516): no archive line — the live store keeps every record,
404
+ // so archiving one would duplicate a live learning. The rewrite still runs:
405
+ // on this role it only appends the batch's NEW ids to the inbox (e.g. the
406
+ // session's fresh learnings from apply-session-learnings) and drops every
407
+ // update, removal and consolidation.
408
+ await rewriteLearnings(filePath, keep, { dryRun: false, malformedLines: malformed });
409
+ return {
410
+ kept: keep.length,
411
+ archived: 0,
412
+ byReason: {},
413
+ malformedField: malformedField(filePath, malformed, malformedLineNumbers),
414
+ skipped: SKIPPED_STORE_ROLE_INBOX,
415
+ };
416
+ }
375
417
  const byReason = countByReason(archiveBatch);
376
418
  const result = {
377
419
  kept: keep.length,
@@ -402,6 +444,18 @@ async function archiveThenRewrite({
402
444
  .join('\n') + '\n';
403
445
  await mkdir(path.dirname(archivePath), { recursive: true });
404
446
  await appendFile(archivePath, body, 'utf8');
447
+ // Inbox receipts (#1516) for archived ids that came from the inbox: the
448
+ // archive is not synced to the inbox host, so without a receipt the record
449
+ // turns visible there again. Written AFTER the archive append (a crash
450
+ // cannot leave a receipt for a record that is neither live nor archived)
451
+ // and BEFORE the rewrite. A crash between the archive append and this
452
+ // receipt still leaves an archived inbox record without one; the next
453
+ // writer rewriteLearnings() heals it (io.mjs `unreceiptedArchivedIds`).
454
+ // Both callers hold the store lock here, as appendInboxReceipts requires.
455
+ await appendInboxReceipts(
456
+ filePath,
457
+ archiveBatch.map(({ entry }) => entry?.id)
458
+ );
405
459
  }
406
460
 
407
461
  // Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
@@ -431,16 +485,24 @@ async function archiveThenRewrite({
431
485
  * malformed?: number}>} `malformed` is present only when the store held
432
486
  * unparseable lines: their count. They are kept verbatim at the end of the
433
487
  * rewritten store, never archived, and not counted in `scanned`/`kept`.
434
- * Never throws on a missing store — returns the zeroed shape instead. Both
488
+ * Never throws on a missing store — returns the zeroed shape instead. An
489
+ * apply on an `inbox`-role host (#1516) reads and writes nothing and returns
490
+ * the zeroed shape plus `skipped: 'store-role-inbox'`. Both
435
491
  * `filePath` and `archivePath` are required; a missing/invalid `filePath`
436
492
  * throws a plain `Error` (programmer error, not a runtime data condition).
493
+ * @throws {LearningsStoreRoleError} an apply on the canonical store while the
494
+ * host's store role is invalid (#1516) — nothing archived or rewritten
437
495
  */
438
496
  export async function sweepExpiredLearnings(opts = {}) {
439
497
  const { filePath, dryRun = true } = opts;
498
+ // Fail-closed (#1516): an apply under an invalid store role refuses before
499
+ // the archive append — archived-but-live records would follow otherwise.
500
+ if (!dryRun && typeof filePath === 'string') assertStoreRoleUsable(filePath);
440
501
  // The read → rewrite runs under the store lock (#1447 point 8). A run that
441
502
  // cannot write takes no lock: a dry run, an absent store (zeroed shape, see
442
503
  // below), or an unusable `filePath` (the body owns that error).
443
- if (dryRun || typeof filePath !== 'string' || !existsSync(filePath)) {
504
+ // An inbox-role apply (#1516) writes nothing either — no lock.
505
+ if (dryRun || typeof filePath !== 'string' || !existsSync(filePath) || isInboxRoleStore(filePath)) {
444
506
  return sweepExpiredLearningsUnlocked(opts);
445
507
  }
446
508
  return withLearningsLock(filePath, () => sweepExpiredLearningsUnlocked(opts));
@@ -464,6 +526,13 @@ async function sweepExpiredLearningsUnlocked({
464
526
  throw new Error('sweepExpiredLearnings: archivePath is required');
465
527
  }
466
528
 
529
+ if (!dryRun && isInboxRoleStore(filePath)) {
530
+ // Inbox role (#1516): an expiry sweep is a pure removal, which this host
531
+ // never performs on the live store — the writer host sweeps. Zeroed counts
532
+ // so no caller reads "archived N"; `skipped` names why.
533
+ return { scanned: 0, kept: 0, archived: 0, dryRun, archivePath, skipped: SKIPPED_STORE_ROLE_INBOX };
534
+ }
535
+
467
536
  if (!existsSync(filePath)) {
468
537
  return { scanned: 0, kept: 0, archived: 0, dryRun, archivePath };
469
538
  }
@@ -575,11 +644,18 @@ async function sweepExpiredLearningsUnlocked({
575
644
  * byReason: Record<string, number>, dryRun: boolean, archivePath: string,
576
645
  * malformed?: number}>} `malformed` as in {@link sweepExpiredLearnings}: the
577
646
  * store's unparseable lines, kept verbatim — no caller generation can carry
578
- * them, so they are never read as caller drops.
647
+ * them, so they are never read as caller drops. On an `inbox`-role host
648
+ * (#1516) an apply archives nothing (`archived: 0`, `byReason: {}`) and adds
649
+ * `skipped: 'store-role-inbox'`; its rewrite only appends the next
650
+ * generation's new ids to `learnings-inbox.jsonl`.
579
651
  * @throws {StoreGenerationMismatchError} when `expectedGeneration` is stale
652
+ * @throws {LearningsStoreRoleError} an apply on the canonical store while the
653
+ * host's store role is invalid (#1516) — nothing archived or rewritten
580
654
  */
581
655
  export async function pruneLearnings(opts = {}) {
582
656
  const { filePath, dryRun = true } = opts;
657
+ // Fail-closed (#1516): refuse before the archive append, see sweepExpiredLearnings.
658
+ if (!dryRun && typeof filePath === 'string') assertStoreRoleUsable(filePath);
583
659
  // The read → archive → rewrite runs under the store lock (#1447 point 8): an
584
660
  // append landing after the read below would otherwise vanish in the rename
585
661
  // with no archive line. A dry run writes nothing and takes no lock; an
@@ -722,7 +798,7 @@ async function pruneLearningsUnlocked({
722
798
  dropped += 1;
723
799
  }
724
800
 
725
- const { kept, archived, byReason, malformedField: malformedCount } = await archiveThenRewrite({
801
+ const { kept, archived, byReason, malformedField: malformedCount, skipped } = await archiveThenRewrite({
726
802
  filePath,
727
803
  archivePath,
728
804
  keep,
@@ -741,6 +817,7 @@ async function pruneLearningsUnlocked({
741
817
  dryRun,
742
818
  archivePath,
743
819
  ...malformedCount,
820
+ ...(skipped ? { skipped } : {}),
744
821
  };
745
822
  }
746
823
 
@@ -842,8 +919,12 @@ function dropSnapshotPath(filePath, nowMs) {
842
919
  * remainingMalformed: number, recordBearing: number, dryRun: boolean, snapshot: string|null}>}
843
920
  * `generation` is the store's as read; `chars` the line's full length, `preview` its first
844
921
  * {@link DROP_PREVIEW_CHARS} characters with the home dir as `~`; `recordBearing` counts the
845
- * dropped lines that held a readable record
922
+ * dropped lines that held a readable record. On an `inbox`-role host (#1516)
923
+ * an apply writes nothing: it returns the dry-run result (`dryRun: true`,
924
+ * `snapshot: null`) plus `skipped: 'store-role-inbox'`
846
925
  * @throws {StoreGenerationMismatchError} the store is not at `expectedGeneration` — nothing written
926
+ * @throws {LearningsStoreRoleError} an apply on the canonical store while the
927
+ * host's store role is invalid (#1516) — nothing written
847
928
  * @throws {MalformedLineRefusedError} a line is not unparseable, or (apply without
848
929
  * `acceptEmbedded`) still holds a readable record — nothing written
849
930
  */
@@ -864,6 +945,17 @@ export async function dropMalformedLines({
864
945
  if (!dryRun && (typeof expectedGeneration !== 'string' || expectedGeneration.length === 0)) {
865
946
  throw new Error('dropMalformedLines: expectedGeneration is required to apply');
866
947
  }
948
+ // Fail-closed (#1516): refuse before the snapshot and the rewrite.
949
+ if (!dryRun) assertStoreRoleUsable(filePath);
950
+ if (!dryRun && isInboxRoleStore(filePath)) {
951
+ // Inbox role (#1516): the live store is never rewritten on this host, so
952
+ // no snapshot and no rewrite. The checks still run (dry), so a stale
953
+ // generation or a wrong line still throws; the result says nothing moved.
954
+ const probe = await dropMalformedLinesUnlocked({
955
+ filePath, lines, expectedGeneration, acceptEmbedded, dryRun: true, now,
956
+ });
957
+ return { ...probe, skipped: SKIPPED_STORE_ROLE_INBOX };
958
+ }
867
959
  const run = () => dropMalformedLinesUnlocked({ filePath, lines, expectedGeneration, acceptEmbedded, dryRun, now });
868
960
  // An absent store has no line to drop — the body refuses without a lock.
869
961
  return dryRun || !existsSync(filePath) ? run() : withLearningsLock(filePath, run);