session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -0,0 +1,178 @@
1
+ /**
2
+ * session-end/tail-runner.mjs — the APPLY half of the Phase 3.6.x tail.
3
+ *
4
+ * `phase-skip.mjs` decides WHICH tail phases should run and is side-effect-free
5
+ * by contract. Until now nothing was the other half for 3.6.4: the
6
+ * Expired-Learnings Sweep was coordinator PROSE, so the dry-run decision was
7
+ * computed on every close and the apply path was never mechanically reached.
8
+ * Census (2026-09-09, `rg -n sweepExpiredLearnings scripts/ hooks/ skills/
9
+ * tests/`): the only call sites were the definition itself, the `dryRun: true`
10
+ * probe in `phase-skip.mjs`, the standalone CLI, and tests — no session-end
11
+ * caller applied anything. Measured consequence across three consumer repos:
12
+ * 0 sweeps ever applied, 628 learnings resident in the active stores.
13
+ *
14
+ * This module closes that gap and nothing else. It is deliberately NOT a
15
+ * general tail executor:
16
+ *
17
+ * - **3.6.3 Memory-Proposals** — AUQ-gated (the operator approves each
18
+ * proposal before it is written); `.claude/rules/ask-via-tool.md` AUQ-004
19
+ * puts the tool out of reach of anything but the coordinator thread.
20
+ * - **3.6.5 Auto-Dream** and **3.6.7 Auto-Dialectic** — both NUDGES that end
21
+ * in a subagent dispatch (memory-cleanup, `dialectic-deriver`); a library
22
+ * function cannot dispatch an agent.
23
+ * - **3.6.6 Skill-Judge** — needs a live LLM dispatch for the judgement.
24
+ * - **3.6.8 Reconcile** — the write is operator-approved per proposal (AUQ),
25
+ * which is the whole never-always-on firewall of Epic #693.
26
+ *
27
+ * So {@link runTailPhases} exists as the SEAM (one dispatch table, one result
28
+ * shape) rather than as an abstraction over six phases that will never all be
29
+ * mechanical. A future phase that becomes mechanical is added here; the five
30
+ * above stay coordinator-executed by design, not by omission.
31
+ *
32
+ * Never-throws contract, and its DIRECTION is the opposite of `phase-skip.mjs`.
33
+ * The planner fails OPEN (probe error → run the phase, because losing a phase
34
+ * silently is worse than running it needlessly). A runner that writes to disk
35
+ * must fail CLOSED: any error yields `{ran: false, reason: 'error'}` and the
36
+ * close proceeds. Session close is the one thing that must never be blocked by
37
+ * a best-effort maintenance sweep — a store that stays unswept for one more
38
+ * session costs nothing; a close that aborts loses the session record.
39
+ *
40
+ * Plain Node ESM, no external deps.
41
+ *
42
+ * @typedef {Object} SweepResult
43
+ * @property {boolean} ran - true only when the store was actually rewritten.
44
+ * @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' | 'error').
45
+ * @property {string} [error] - error message, `reason === 'error'` only.
46
+ * @property {number} [scanned] - entries read from the active store (ran only).
47
+ * @property {number} [archived] - entries moved to the archive sidecar (ran only).
48
+ * @property {string} [archivePath] - the archive sidecar written to (ran only).
49
+ */
50
+
51
+ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
52
+ import { resolveLearningsPaths } from './phase-skip.mjs';
53
+
54
+ /** Event name — a plain string literal so the events-schema census can see it. */
55
+ const SWEEP_EVENT = 'orchestrator.learnings.sweep_applied';
56
+
57
+ /** Payload marker for the producer, so the ledger separates it from the CLI. */
58
+ const SWEEP_SOURCE = 'session-end-3.6.4';
59
+
60
+ /**
61
+ * Pull the decision for one phase id out of a `planTailPhases()` result.
62
+ *
63
+ * Accepts three shapes so callers need no adapter: the full
64
+ * `{plan, skippedReport}` envelope, a bare `PhaseDecision[]`, or a single
65
+ * `PhaseDecision` object. Anything else → `null` (treated as "no plan").
66
+ *
67
+ * @param {object|Array|undefined} plan
68
+ * @param {string} phase
69
+ * @returns {object|null}
70
+ */
71
+ function findPhaseDecision(plan, phase) {
72
+ if (!plan) return null;
73
+ const list = Array.isArray(plan) ? plan : Array.isArray(plan.plan) ? plan.plan : null;
74
+ if (list) return list.find((d) => d && d.phase === phase) ?? null;
75
+ return plan.phase === phase ? plan : null;
76
+ }
77
+
78
+ /**
79
+ * Emit the one sweep record. Best-effort by construction: a telemetry failure
80
+ * never changes the caller's return value.
81
+ *
82
+ * The shape (lazy `events.mjs` import, explicit `repoRoot`, silent catch) is
83
+ * copied from `_emitEvaluated` in `scripts/lib/express-path.mjs`. Both halves
84
+ * are load-bearing: the lazy import keeps `platform.mjs`'s load-time filesystem
85
+ * walk-up out of the static graph of a module the planner imports, and the
86
+ * refusal to emit WITHOUT an explicit `repoRoot` prevents the `SO_PROJECT_DIR`
87
+ * fallback from writing a synthetic record into whatever tree the ambient env
88
+ * happens to resolve to (#941 — that mistake once landed a test record in the
89
+ * operator's real fleet ledger).
90
+ *
91
+ * @param {{repoRoot: string, scanned: number, archived: number}} ctx
92
+ * @returns {Promise<void>}
93
+ */
94
+ async function emitSweepApplied({ repoRoot, scanned, archived }) {
95
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
96
+ process.stderr.write(
97
+ `tail-runner: skipped ${SWEEP_EVENT} — no repoRoot given; ` +
98
+ 'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
99
+ );
100
+ return;
101
+ }
102
+ try {
103
+ const { emitEvent, sessionAttribution } = await import('../events.mjs');
104
+ await emitEvent(
105
+ SWEEP_EVENT,
106
+ { scanned, archived, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
107
+ { repoRoot },
108
+ );
109
+ } catch {
110
+ // Best-effort telemetry — the sweep already happened and its result is
111
+ // authoritative whether or not the ledger accepted the record.
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Phase 3.6.4 — apply the Expired-Learnings Sweep the planner decided on.
117
+ *
118
+ * Runs ONLY when the plan's 3.6.4 decision says `run === true`. Any other
119
+ * shape (skip decision, phase absent, no plan at all) returns without touching
120
+ * disk — the planner owns the decision, this function owns the write, and a
121
+ * runner that re-derives the decision would be free to disagree with it.
122
+ *
123
+ * @param {object} args
124
+ * @param {string} args.repoRoot Absolute repo root.
125
+ * @param {object|Array} [args.plan] `planTailPhases()` result, its `plan`
126
+ * array, or the bare 3.6.4 decision.
127
+ * @param {Date|number} [args.now] Injectable clock (grace-window maths).
128
+ * @param {number} [args.graceDays] Override the 14-day grace window.
129
+ * @param {boolean} [args.emit=true] Set false to suppress the ledger record.
130
+ * @returns {Promise<SweepResult>} Never throws.
131
+ */
132
+ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = true } = {}) {
133
+ try {
134
+ const decision = findPhaseDecision(plan, '3.6.4');
135
+ if (!decision) return { ran: false, reason: 'no-plan' };
136
+ if (decision.run !== true) {
137
+ return { ran: false, reason: 'plan-skip', planReason: decision.reason };
138
+ }
139
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
140
+ return { ran: false, reason: 'error', error: 'runExpiredSweep: repoRoot is required' };
141
+ }
142
+
143
+ const { filePath, archivePath } = resolveLearningsPaths(repoRoot);
144
+ const res = await sweepExpiredLearnings({
145
+ filePath,
146
+ archivePath,
147
+ now,
148
+ dryRun: false,
149
+ ...(graceDays === undefined ? {} : { graceDays }),
150
+ });
151
+
152
+ const scanned = res?.scanned ?? 0;
153
+ const archived = res?.archived ?? 0;
154
+ if (emit) await emitSweepApplied({ repoRoot, scanned, archived });
155
+
156
+ return { ran: true, scanned, archived, archivePath: res?.archivePath ?? archivePath };
157
+ } catch (err) {
158
+ // Fail CLOSED (see the module header): a maintenance sweep must never be
159
+ // able to block a session close.
160
+ return { ran: false, reason: 'error', error: err && err.message ? err.message : String(err) };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Run every MECHANICAL phase of the session-end tail.
166
+ *
167
+ * Today that is exactly one — 3.6.4. See the module header for why 3.6.3 and
168
+ * 3.6.5–3.6.8 stay coordinator-executed (AUQ-gated per
169
+ * `.claude/rules/ask-via-tool.md` AUQ-004, or requiring a subagent dispatch a
170
+ * library function cannot make). The keyed return shape is the seam: a caller
171
+ * reads `result['3.6.4']` today and keeps compiling when a second phase lands.
172
+ *
173
+ * @param {object} args — forwarded verbatim to {@link runExpiredSweep}.
174
+ * @returns {Promise<Record<string, SweepResult>>} Never throws.
175
+ */
176
+ export async function runTailPhases({ repoRoot, plan, now, graceDays, emit } = {}) {
177
+ return { '3.6.4': await runExpiredSweep({ repoRoot, plan, now, graceDays, emit }) };
178
+ }
@@ -492,6 +492,11 @@ export function readLockDetailed(opts = {}) {
492
492
  * recorded pids dead, INCLUDING the currently heartbeating session's
493
493
  * own lock), so `stale-pid-alive` was unreachable same-host and every
494
494
  * stale lock rendered as "confirmed dead" in the recovery AUQ.
495
+ * { ok: false, reason: 'missing-session-id' }
496
+ * — no usable `sessionId` given; NOTHING was written. Same reason string
497
+ * and same predicate as forceAcquire() (see its docblock for why an
498
+ * ownerless lock is worse than no lock). No `exclusivityClass`: the
499
+ * call never reached classification.
495
500
  * { ok: false, reason: 'fs-error', error, exclusivityClass? }
496
501
  * — filesystem failure
497
502
  * { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
@@ -509,6 +514,28 @@ export function readLockDetailed(opts = {}) {
509
514
  * obtaining user consent.
510
515
  */
511
516
  export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, activeSessions, semanticSessionId, quiet = false } = {}) {
517
+ // -------------------------------------------------------------------------
518
+ // Ownerless-lock guard. Measured 2026-09-11 in a tmp repoRoot:
519
+ // acquire({sessionId: undefined}) -> {ok:true}, lockfile written with NO
520
+ // `session_id` key at all; acquire({sessionId: ''}) -> {ok:true}, lockfile
521
+ // written with `"session_id": ""`. Both are the exact "present but owned by
522
+ // nobody" state forceAcquire()'s docblock below describes: every reader
523
+ // compares such a lock against its own id, finds no match, classifies it
524
+ // FOREIGN — and foreign is the classification that SKIPS enforcement.
525
+ // The guard lived only on forceAcquire(), the rare path (explicit
526
+ // stale-lock takeover); acquire() is the primary one. The production
527
+ // caller hooks/_lib/lock-bootstrap.mjs gates empty ids upstream, but the
528
+ // prose path skills/session-start/references/phase-1-2-session-lock.md
529
+ // calls acquire() straight from the coordinator LLM with no gate at all.
530
+ // Placed FIRST, before classifyMode and before any fs work: nothing is
531
+ // written. `exclusivityClass` is deliberately absent from this shape — the
532
+ // caller's class is a property of a call that got far enough to be
533
+ // classified, and this one did not.
534
+ // -------------------------------------------------------------------------
535
+ if (!hasUsableSessionId(sessionId)) {
536
+ return { ok: false, reason: 'missing-session-id' };
537
+ }
538
+
512
539
  const lockFile = lockPathFor(repoRoot);
513
540
 
514
541
  // -------------------------------------------------------------------------
@@ -671,12 +698,45 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
671
698
  * Call only after the user has explicitly authorised stale-lock takeover.
672
699
  *
673
700
  * Returns:
674
- * { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
675
- * { ok: false, reason: 'fs-error', ... } filesystem failure
701
+ * { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
702
+ * { ok: false, reason: 'missing-session-id' } no usable `sessionId` given; NOTHING was written
703
+ * { ok: false, reason: 'fs-error', ... } — filesystem failure
704
+ *
705
+ * The `missing-session-id` guard is not cosmetic. `buildLock` assigns
706
+ * `session_id: sessionId` verbatim, and `JSON.stringify` DROPS an `undefined`
707
+ * value — so a `forceAcquire()` without a sessionId used to write a lock file
708
+ * carrying no `session_id` key at all. Such a lock is present but owned by
709
+ * nobody: every reader compares it against its own id, finds no match, and
710
+ * classifies it as FOREIGN — and foreign is precisely the classification that
711
+ * skips enforcement. The same failure class is why the wave scope manifest
712
+ * forbids `"session_id": ""` and requires the key be omitted instead.
713
+ *
714
+ * The result SHAPE (not a throw) is deliberate and matches the callers: the
715
+ * only production call site, `hooks/_lib/lock-bootstrap.mjs`, branches on
716
+ * `acquireResult.ok !== true` and bails non-blocking; a throw there would be
717
+ * swallowed by its `catch` and reported as the same `null`, i.e. it would add
718
+ * no information while breaking the direct `forceAcquire()` calls documented in
719
+ * `skills/session-start/references/phase-1-2-session-lock.md`.
676
720
  *
677
721
  * @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string }} args
678
722
  */
723
+ /**
724
+ * A `sessionId` is usable only when it is a non-blank string. ONE predicate,
725
+ * shared by acquire() and forceAcquire(), so the two entry points cannot drift
726
+ * apart again — they did: the guard existed on forceAcquire() alone until
727
+ * 2026-09-11, leaving the primary path able to write an ownerless lock.
728
+ *
729
+ * @param {unknown} sessionId
730
+ * @returns {boolean}
731
+ */
732
+ function hasUsableSessionId(sessionId) {
733
+ return typeof sessionId === 'string' && sessionId.trim().length > 0;
734
+ }
735
+
679
736
  export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId } = {}) {
737
+ if (!hasUsableSessionId(sessionId)) {
738
+ return { ok: false, reason: 'missing-session-id' };
739
+ }
680
740
  try {
681
741
  const replacedLock = readLock({ repoRoot });
682
742
  const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
@@ -42,6 +42,14 @@
42
42
  * had defaulted, which is what lets a downstream consumer tell a measured zero
43
43
  * apart from a repaired-to-zero.
44
44
  *
45
+ * It also never DISCARDS what it replaces: any field whose present value is
46
+ * overwritten by a default is first copied to a `_<field>_raw` sidecar
47
+ * (`preserveRaw`, same convention and same non-clobber guard as
48
+ * `session-schema/normalizer.mjs`'s `_express_path_detail`). Without it the
49
+ * repair was itself a data-loss event — 7 of 40 records in the
50
+ * `projects-baseline` ledger (S119, 2026-09-10) lost narrative `agent_summary`
51
+ * strings and `total_files_changed` path lists to `{...0}` / `0`.
52
+ *
45
53
  * Plain Node ESM. Named exports. DI-friendly via `deps`.
46
54
  *
47
55
  * Cross-references:
@@ -112,6 +120,70 @@ function orderIncompleteFields(fields) {
112
120
  return [...fields].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
113
121
  }
114
122
 
123
+ /**
124
+ * Preserve the pre-repair value of `key` under a `_<key>_raw` sidecar BEFORE a
125
+ * default overwrites it.
126
+ *
127
+ * Convention lifted from `session-schema/normalizer.mjs`
128
+ * (`_express_path_detail`, `if (!('_express_path_detail' in next))`) — but its
129
+ * non-clobber guard is keyed on WHO WROTE the sidecar, not on its mere presence.
130
+ * That distinction is the fix for a measured data-loss bug (2026-09-11): the
131
+ * presence guard read `out`, and `out = { ...record }` carries the INPUT's key
132
+ * space, so a record arriving with its own `_agent_summary_raw` kept that
133
+ * unverifiable value and dropped the real `agent_summary` the repair was about
134
+ * to overwrite:
135
+ *
136
+ * in: agent_summary: 'ECHTE NARRATIVE ZUSAMMENFASSUNG',
137
+ * _agent_summary_raw: 'ANGREIFER-WERT'
138
+ * out: _agent_summary_raw = 'ANGREIFER-WERT' ← the real value was gone
139
+ *
140
+ * That is the mirror image of the promise this module's docblock makes, in the
141
+ * one case where the promise matters. So: `rescued` — a per-`repairRecord()`-call
142
+ * Set of sidecar keys THIS pass has written — replaces `sidecar in out`. A value
143
+ * that is provably real RIGHT NOW always outranks a sidecar of unknown origin.
144
+ *
145
+ * The multi-pass concern the old comment named is not what kept the sidecar
146
+ * safe, and was measured unreachable: after run 1 the field holds a VALID
147
+ * default, so run 2 records no defect for it and never calls preserveRaw at all
148
+ * (measured: `repairRecord(repairRecord(x).record).changed === false`, defects
149
+ * `[]`, sidecar intact). Within ONE pass the Set still gives first-write-wins,
150
+ * which is what the two branches per field (waves, agent_summary) rely on.
151
+ *
152
+ * NAMED CEILING (BV-004): if a record is RE-CORRUPTED with a different real
153
+ * value for the same key BETWEEN two repair runs, run 2's original wins and run
154
+ * 1's rescue is dropped. Reaching it requires corruption after a repair; the
155
+ * alternative — trusting an input-supplied sidecar over a value that is
156
+ * demonstrably real — is the bug above. Revisit if a ledger is ever repaired
157
+ * in a loop that can re-break the same key.
158
+ *
159
+ * Motivation is measured, not hypothetical: 7 of 40 repaired records in the
160
+ * `projects-baseline` ledger (S119, 2026-09-10) carried narrative strings in
161
+ * `agent_summary` and path lists in `total_files_changed`; both were replaced
162
+ * by `{...0}` / `0` and had to be restored by hand. This module's own docblock
163
+ * promises "nothing is invented" — discarding an unreadable original is the
164
+ * mirror-image violation of that promise.
165
+ *
166
+ * `undefined` and `null` are NOT preserved: an absent field has nothing to
167
+ * lose (the defect classes are named `*_absent` / `*_missing` for exactly that
168
+ * reason), and `JSON.stringify` drops an `undefined` sidecar anyway.
169
+ *
170
+ * The sidecars are schema-safe: `validateSession` has no unknown-key rejection
171
+ * (see `validator.mjs` — every `_validate*` helper checks named fields only),
172
+ * and `serializeSessionLineChecked` round-trips extra keys untouched.
173
+ *
174
+ * @param {Record<string, any>} out — the mutable repaired copy
175
+ * @param {string} key — the field about to be defaulted
176
+ * @param {any} rawValue — its ORIGINAL value (read from the untouched input)
177
+ * @param {Set<string>} rescued — sidecar keys already written by THIS call
178
+ */
179
+ function preserveRaw(out, key, rawValue, rescued) {
180
+ if (rawValue === undefined || rawValue === null) return;
181
+ const sidecar = `_${key}_raw`;
182
+ if (rescued.has(sidecar)) return;
183
+ out[sidecar] = rawValue;
184
+ rescued.add(sidecar);
185
+ }
186
+
115
187
  /** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
116
188
  export function backupStamp(date = new Date()) {
117
189
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
@@ -143,6 +215,9 @@ export function repairRecord(record) {
143
215
  const out = { ...record };
144
216
  const defects = [];
145
217
  const incomplete = new Set();
218
+ // Sidecar keys written by THIS pass — the provenance the non-clobber guard
219
+ // needs. `out`'s own key space cannot serve: it is the INPUT's. See preserveRaw.
220
+ const rescued = new Set();
146
221
 
147
222
  // -- waves ----------------------------------------------------------------
148
223
  // A NUMBER here is not garbage: it IS the wave count, written by an older
@@ -158,6 +233,7 @@ export function repairRecord(record) {
158
233
  } else {
159
234
  defects.push('waves_not_array');
160
235
  }
236
+ preserveRaw(out, 'waves', record.waves, rescued);
161
237
  out.waves = [];
162
238
  incomplete.add('waves');
163
239
  }
@@ -173,6 +249,11 @@ export function repairRecord(record) {
173
249
  if (out.waves.length > 0 && out.waves.every(isPlainObject)) {
174
250
  const needsRenumber = out.waves.some((w) => !isCount(w.wave) || w.wave < 1);
175
251
  if (needsRenumber) {
252
+ // The ORIGINAL ordinals are not recoverable from the renumbered output
253
+ // (the input may mix valid, absent and 0-based `wave` values), so the
254
+ // pre-renumber array is preserved whole — same sidecar as the
255
+ // not-an-array branch above, which cannot have fired on this path.
256
+ preserveRaw(out, 'waves', record.waves, rescued);
176
257
  out.waves = out.waves.map((w, i) => ({ ...w, wave: i + 1 }));
177
258
  defects.push('wave_index_invalid');
178
259
  incomplete.add('waves[].wave');
@@ -181,6 +262,7 @@ export function repairRecord(record) {
181
262
 
182
263
  // -- total_waves ----------------------------------------------------------
183
264
  if (!isCount(out.total_waves)) {
265
+ preserveRaw(out, 'total_waves', record.total_waves, rescued);
184
266
  out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
185
267
  defects.push('total_waves_missing');
186
268
  incomplete.add('total_waves');
@@ -188,12 +270,18 @@ export function repairRecord(record) {
188
270
 
189
271
  // -- agent_summary --------------------------------------------------------
190
272
  if (!isPlainObject(out.agent_summary)) {
273
+ preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
191
274
  out.agent_summary = { complete: 0, partial: 0, failed: 0, spiral: 0 };
192
275
  defects.push('agent_summary_absent');
193
276
  incomplete.add('agent_summary');
194
277
  } else {
195
278
  const missing = AGENT_SUMMARY_FIELDS.filter((f) => !isCount(out.agent_summary[f]));
196
279
  if (missing.length > 0) {
280
+ // Preserve the WHOLE original summary object rather than one sidecar per
281
+ // defaulted counter: it carries every original field value at once and
282
+ // keeps the sidecar namespace flat (`_agent_summary.spiral_raw` would be
283
+ // a second, uglier convention for the same job).
284
+ preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
197
285
  out.agent_summary = { ...out.agent_summary };
198
286
  for (const f of missing) {
199
287
  out.agent_summary[f] = 0;
@@ -209,6 +297,7 @@ export function repairRecord(record) {
209
297
 
210
298
  // -- total_agents ---------------------------------------------------------
211
299
  if (!isCount(out.total_agents)) {
300
+ preserveRaw(out, 'total_agents', record.total_agents, rescued);
212
301
  // Prefer the record's own evidence: an agent_summary PRESENT in the
213
302
  // original sums to the real agent count (live line 71 sums to 30 where
214
303
  // waves.length is 5 — W2/A4 review finding). Fall back to waves.length
@@ -226,6 +315,7 @@ export function repairRecord(record) {
226
315
 
227
316
  // -- total_files_changed --------------------------------------------------
228
317
  if (!isCount(out.total_files_changed)) {
318
+ preserveRaw(out, 'total_files_changed', record.total_files_changed, rescued);
229
319
  out.total_files_changed = 0;
230
320
  defects.push('total_files_changed_missing');
231
321
  incomplete.add('total_files_changed');
@@ -239,6 +329,7 @@ export function repairRecord(record) {
239
329
  // i.e. "unknown", and flagged as such. NEVER the next record's timestamp:
240
330
  // the ledger is not chronologically ordered (see the module docblock).
241
331
  if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
332
+ preserveRaw(out, 'completed_at', record.completed_at, rescued);
242
333
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
243
334
  const startedMs = Date.parse(out.started_at);
244
335
  out.completed_at =
@@ -161,6 +161,12 @@ export const OPTIONAL_FIELDS = Object.freeze([
161
161
  'total_token_input',
162
162
  'total_token_output',
163
163
  'subagents_with_tokens',
164
+ // #1244 — cache buckets + cost estimate (additive, optional).
165
+ 'total_token_input_uncached',
166
+ 'total_token_cache_read',
167
+ 'total_token_cache_creation',
168
+ 'total_cost_usd',
169
+ '_token_schema',
164
170
  // Epic #724 C1 — SessionEnd close-through backfill provenance fields.
165
171
  // `status` distinguishes normally-closed ('completed') from hook-backfilled
166
172
  // ('abandoned') records. The `_backfill_*` markers record how a stub was
@@ -33,7 +33,8 @@
33
33
  * schema migrations and dedup checks all legitimately need the phantoms. Use
34
34
  * these helpers only where the window is meant to represent REAL WORK.
35
35
  *
36
- * Exports: isRealSession, filterRealSessions, tailRealSessions
36
+ * Exports: isRealSession, filterRealSessions, tailRealSessions,
37
+ * isCoordinatorDirectHousekeeping
37
38
  */
38
39
 
39
40
  // ---------------------------------------------------------------------------
@@ -86,3 +87,27 @@ export function tailRealSessions(records, n) {
86
87
  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return [];
87
88
  return real.slice(-Math.floor(n));
88
89
  }
90
+
91
+ /**
92
+ * True when a record's waves are ALL coordinator-direct `Housekeeping` waves —
93
+ * the shape the session-end writer rule (#1321) records for a session that ran
94
+ * no dispatched waves. The single definition of that shape: consumers treat such
95
+ * a record as "no waves ran" (eval gate-health) and exclude its 0-agent waves
96
+ * from agents-per-wave ratios.
97
+ *
98
+ * Keys on the wave SHAPE only, never on `session_type`: a housekeeping session
99
+ * that ran real waves is not this shape. Nor on `coordinator_direct` alone —
100
+ * whole multi-wave feature sessions carry it too.
101
+ *
102
+ * @param {unknown} record — a parsed sessions.jsonl entry
103
+ * @returns {boolean} — false for empty/absent `waves`
104
+ */
105
+ export function isCoordinatorDirectHousekeeping(record) {
106
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) return false;
107
+ const { waves } = record;
108
+ return (
109
+ Array.isArray(waves) &&
110
+ waves.length > 0 &&
111
+ waves.every((w) => w?.role === 'Housekeeping' && w?.coordinator_direct === true)
112
+ );
113
+ }
@@ -384,6 +384,26 @@ function _validateOptionalFields(entry) {
384
384
  );
385
385
  }
386
386
  }
387
+ // #1244 — cache buckets, cost estimate and the token-contract marker.
388
+ // Same non-negative-finite-or-null contract as the two totals above;
389
+ // total_cost_usd is fractional and null means "unknown model", never 0.
390
+ for (const field of [
391
+ 'total_token_input_uncached',
392
+ 'total_token_cache_read',
393
+ 'total_token_cache_creation',
394
+ 'total_cost_usd',
395
+ '_token_schema',
396
+ ]) {
397
+ const value = entry[field];
398
+ if (value !== undefined && value !== null) {
399
+ if (!Number.isFinite(value) || value < 0) {
400
+ throw new ValidationError(
401
+ `${field} must be a non-negative finite number or null, got: ${value}`
402
+ );
403
+ }
404
+ }
405
+ }
406
+
387
407
  // subagents_with_tokens: non-negative integer (never null — it is always a count, defaulting to 0).
388
408
  if (entry.subagents_with_tokens !== undefined && entry.subagents_with_tokens !== null) {
389
409
  if (