session-orchestrator 4.1.0 → 4.2.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 (137) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +34 -0
  9. package/README.md +8 -8
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/session.md +3 -2
  16. package/docs/README.md +4 -4
  17. package/docs/USER-GUIDE.md +115 -48
  18. package/docs/agent-authoring.md +2 -2
  19. package/docs/baseline.md +55 -1
  20. package/docs/ci-setup.md +1 -1
  21. package/docs/codex-setup.md +1 -0
  22. package/docs/components.md +2 -2
  23. package/docs/cursor-setup.md +1 -0
  24. package/docs/events-schema.md +4 -1
  25. package/docs/instruction-delivery.md +1 -1
  26. package/docs/memory-proposal-flow.md +3 -3
  27. package/docs/migration-v4.md +2 -2
  28. package/docs/owner-config-schema.md +74 -90
  29. package/docs/persona-panel.md +4 -4
  30. package/docs/pi-setup.md +1 -0
  31. package/docs/rule-authoring.md +13 -6
  32. package/docs/scope-collision-guard.md +2 -0
  33. package/docs/session-config-reference.md +55 -22
  34. package/docs/session-config-template.md +9 -5
  35. package/docs/vault-docs-architecture.md +4 -2
  36. package/hooks/_lib/hook-import-set.json +28 -3
  37. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks.json +1 -1
  40. package/hooks/pre-bash-issue-budget.mjs +123 -26
  41. package/hooks/subagent-telemetry.mjs +106 -20
  42. package/package.json +4 -4
  43. package/scripts/baseline-archetypes.mjs +28 -0
  44. package/scripts/lib/auto-dialectic.mjs +0 -68
  45. package/scripts/lib/baseline-archetypes.mjs +439 -0
  46. package/scripts/lib/build-live-signals.mjs +5 -6
  47. package/scripts/lib/config/issue-budget.mjs +68 -8
  48. package/scripts/lib/config/private-config-dir.mjs +3 -2
  49. package/scripts/lib/config/remote-hosts.mjs +2 -2
  50. package/scripts/lib/config-schema.mjs +79 -0
  51. package/scripts/lib/file-lock.mjs +47 -5
  52. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  53. package/scripts/lib/issue-budget.mjs +76 -3
  54. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  55. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  56. package/scripts/lib/owner-config.example.yaml +29 -46
  57. package/scripts/lib/owner-yaml.mjs +14 -13
  58. package/scripts/lib/quality-gate.mjs +13 -6
  59. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  60. package/scripts/lib/rules-sync.mjs +34 -4
  61. package/scripts/lib/session-close-backfill.mjs +182 -40
  62. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  63. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  64. package/scripts/lib/session-schema/constants.mjs +6 -0
  65. package/scripts/lib/session-schema/validator.mjs +20 -0
  66. package/scripts/lib/session-shape.mjs +558 -0
  67. package/scripts/lib/session-start-probes.mjs +10 -3
  68. package/scripts/lib/session-token-rollup.mjs +95 -10
  69. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  70. package/scripts/lib/state-md.mjs +1 -0
  71. package/scripts/lib/subagents-schema.mjs +77 -9
  72. package/scripts/lib/telemetry/pricing.mjs +197 -0
  73. package/scripts/lib/telemetry/sync.mjs +50 -1
  74. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  75. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  76. package/scripts/lib/vault-mirror/process.mjs +2 -1
  77. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  78. package/scripts/lib/wave-resource-gate.mjs +23 -27
  79. package/scripts/lib/wave-sizing.mjs +10 -3
  80. package/scripts/materialize-wave-scope.mjs +68 -14
  81. package/scripts/print-applicable-rules.mjs +7 -6
  82. package/scripts/print-learnings-index.mjs +3 -2
  83. package/scripts/session-shape.mjs +266 -0
  84. package/skills/_shared/config-reading.md +15 -9
  85. package/skills/_shared/private-capability-context.md +89 -0
  86. package/skills/bootstrap/SKILL.md +61 -13
  87. package/skills/bootstrap/_shared-template.md +99 -14
  88. package/skills/bootstrap/deep-template.md +36 -26
  89. package/skills/bootstrap/fast-template.md +44 -8
  90. package/skills/bootstrap/intensity-heuristic.md +10 -4
  91. package/skills/bootstrap/private-contract.md +119 -0
  92. package/skills/bootstrap/public-fallback.md +30 -18
  93. package/skills/bootstrap/standard-template.md +39 -24
  94. package/skills/discovery/probes-ui.md +1 -1
  95. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  96. package/skills/evolve/SKILL.md +2 -2
  97. package/skills/gitlab-ops/SKILL.md +3 -3
  98. package/skills/grill/SKILL.md +1 -1
  99. package/skills/memory-cleanup/SKILL.md +2 -2
  100. package/skills/plan/mode-new.md +9 -0
  101. package/skills/reconcile/SKILL.md +1 -1
  102. package/skills/session-end/SKILL.md +3 -2
  103. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  104. package/skills/session-end/phase-3-6-tail.md +23 -65
  105. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  106. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  107. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  108. package/skills/session-end/session-metrics-write.md +31 -12
  109. package/skills/session-plan/SKILL.md +56 -48
  110. package/skills/session-plan/wave-template.md +8 -15
  111. package/skills/session-start/SKILL.md +18 -2
  112. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  113. package/skills/session-start/phase-8-5-express-path.md +12 -9
  114. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  115. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  116. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  117. package/skills/test-runner/rubric-v1.md +2 -2
  118. package/skills/wave-executor/SKILL.md +42 -12
  119. package/skills/wave-executor/circuit-breaker.md +3 -1
  120. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  121. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  122. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  123. package/templates/nextjs-minimal/package.json +1 -1
  124. package/templates/node-minimal/package.json +1 -1
  125. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  126. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  127. package/scripts/lib/owner-config/coerce.mjs +0 -29
  128. package/scripts/lib/owner-config/constants.mjs +0 -21
  129. package/scripts/lib/owner-config/defaults.mjs +0 -50
  130. package/scripts/lib/owner-config/error.mjs +0 -19
  131. package/scripts/lib/owner-config/index.mjs +0 -13
  132. package/scripts/lib/owner-config/merge.mjs +0 -52
  133. package/scripts/lib/owner-config/validate.mjs +0 -259
  134. package/scripts/lib/owner-config-loader.mjs +0 -170
  135. package/scripts/lib/owner-config.mjs +0 -28
  136. package/scripts/lib/soul-resolve.mjs +0 -130
  137. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -6,11 +6,23 @@
6
6
  * the close-out abort-attractor: six phases, each ~50 lines of coordinator prose,
7
7
  * that in the overwhelming majority of sessions do nothing (no proposals queued,
8
8
  * nothing expired, under cadence, judge off, reconcile off). This aggregator
9
- * computes — side-effect-free — WHICH of the six should actually run, so the
9
+ * computes — side-effect-free — WHICH of them should actually run, so the
10
10
  * coordinator loads only the detail procedure for the `run: true` phases and
11
11
  * emits a one-line `skippedReport` for the rest.
12
12
  *
13
- * Every one of the six phases already ships a mechanical fast-path in its own lib
13
+ * FOUR phases are still LIVE and decided here: 3.6.3, 3.6.4, 3.6.6, 3.6.8.
14
+ * TWO are RETIRED (2026-09-09) and always emit a `run: false` entry with
15
+ * `inputSource: 'retired'`: 3.6.5 Auto-Dream and 3.6.7 Auto-Dialectic. Both
16
+ * nudges are now computed once at SESSION-START by the `maintenance-due` probe
17
+ * (`scripts/lib/maintenance-due-banner.mjs`), which runs the same
18
+ * `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic` signals and
19
+ * recommends `/session housekeeping`. Measured across three consumer repos the
20
+ * close-time nudges fired (`orchestrator.dialectic.nudge_decided`,
21
+ * `decided: true`, sessions_since 23/8/6) and were never acted on — computing
22
+ * them at every close was pure cost. The plan SHAPE is unchanged (still six
23
+ * entries, same ids, same order) so every reader keeps working.
24
+ *
25
+ * Each of the four live phases already ships a mechanical fast-path in its own lib
14
26
  * (a config kill-switch, an `existsSync` short-circuit, or a cadence/trigger
15
27
  * decision). This module WRAPS those existing signals — it never re-implements
16
28
  * their logic. Config gates run FIRST as the cheap short-circuit (no disk touch),
@@ -34,9 +46,10 @@
34
46
  * @property {boolean} run - true → coordinator runs the detail procedure.
35
47
  * @property {string} reason - human-readable reason for the decision.
36
48
  * @property {string} inputSource - which signal drove the decision
37
- * ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' | 'auto-dream-signal' |
38
- * 'skill-invocations.jsonl' | 'auto-dialectic-signal' | 'reconcile-dry-run' |
39
- * 'learnings.jsonl' | 'probe-error').
49
+ * ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' |
50
+ * 'skill-invocations.jsonl' | 'reconcile-dry-run' | 'learnings.jsonl' |
51
+ * 'retired' | 'probe-error'). `'retired'` is emitted only by the two
52
+ * permanently-skipped phases 3.6.5 / 3.6.7 — no signal was consulted at all.
40
53
  * @property {string[]} [targets] - 3.6.8 only, RUN decisions only: the effective
41
54
  * reconcile write-targets (issue #1099). Additive — absent on every other phase.
42
55
  * @property {string|null} [baselineRoot] - 3.6.8 only, RUN decisions only: the
@@ -52,11 +65,38 @@ import path from 'node:path';
52
65
 
53
66
  import { collectProposals } from '../memory-proposals/collector.mjs';
54
67
  import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
55
- import { shouldDispatchAutoDream } from '../auto-dream.mjs';
56
- import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
57
68
  import { readSkillInvocations } from '../skill-invocations-schema.mjs';
58
69
  import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
59
- import { resolveMemoryDir } from '../memory-paths.mjs';
70
+
71
+ // NOTE (2026-09-09): `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic`
72
+ // are deliberately NOT imported here any more — 3.6.5 and 3.6.7 are retired and
73
+ // the two signals are computed once at session-start by the `maintenance-due`
74
+ // probe. Re-adding either import is the regression this module's test suite
75
+ // guards against.
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Shared path resolution
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Resolve the learnings store + archive sidecar paths for a repo.
83
+ *
84
+ * Exported because `session-end/tail-runner.mjs` (the APPLY counterpart to the
85
+ * 3.6.4 dry-run decision below) must sweep exactly the pair this decision
86
+ * probed. Two hand-written `path.join(repoRoot, '.orchestrator', 'metrics', …)`
87
+ * recipes are one rename away from the planner deciding on one store while the
88
+ * runner rewrites another.
89
+ *
90
+ * @param {string} repoRoot
91
+ * @returns {{filePath: string, archivePath: string}}
92
+ */
93
+ export function resolveLearningsPaths(repoRoot) {
94
+ const dir = path.join(repoRoot, '.orchestrator', 'metrics');
95
+ return {
96
+ filePath: path.join(dir, 'learnings.jsonl'),
97
+ archivePath: path.join(dir, 'learnings-archive.jsonl'),
98
+ };
99
+ }
60
100
 
61
101
  // ---------------------------------------------------------------------------
62
102
  // Decision constructors
@@ -92,6 +132,23 @@ function mkProbeError(phase, err) {
92
132
  return decision(phase, true, `probe-error: ${msg}`, 'probe-error');
93
133
  }
94
134
 
135
+ /** Phase ids retired on 2026-09-09 — kept in the plan, never decided. */
136
+ const RETIRED_PHASES = new Set(['3.6.5', '3.6.7']);
137
+
138
+ /** The reason string every retired phase carries, verbatim. */
139
+ const RETIRED_REASON = 'retired 2026-09-09 — replaced by session-start maintenance-due probe';
140
+
141
+ /**
142
+ * Permanent skip for a retired phase. No signal is read and no config key is
143
+ * consulted — not even a kill-switch, because there is nothing left to switch.
144
+ *
145
+ * @param {string} phase
146
+ * @returns {PhaseDecision}
147
+ */
148
+ function mkRetired(phase) {
149
+ return mkSkip(phase, RETIRED_REASON, 'retired');
150
+ }
151
+
95
152
  // ---------------------------------------------------------------------------
96
153
  // Config / platform helpers
97
154
  // ---------------------------------------------------------------------------
@@ -101,17 +158,6 @@ function isPersistenceOff(cfg) {
101
158
  return cfg && cfg.persistence === false;
102
159
  }
103
160
 
104
- /**
105
- * MEMORY.md (Auto-Dream signal source) lives under `~/.claude/projects/` — a
106
- * Claude Code-only path. Absent platform → treated as Claude Code (the common
107
- * coordinator context); any explicit non-Claude platform gates 3.6.5 off.
108
- */
109
- function isClaudePlatform(platform) {
110
- if (platform === undefined || platform === null || platform === '') return true;
111
- const p = String(platform).toLowerCase();
112
- return p === 'claude' || p === 'claude-code' || p === 'claudecode';
113
- }
114
-
115
161
  // ---------------------------------------------------------------------------
116
162
  // Per-phase deciders — each wraps an EXISTING signal helper, config-gate first.
117
163
  // ---------------------------------------------------------------------------
@@ -149,14 +195,8 @@ async function decideMemoryProposals({ repoRoot, cfg }) {
149
195
  async function decideExpiredSweep({ repoRoot }) {
150
196
  const phase = '3.6.4';
151
197
  try {
152
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
198
+ const { filePath, archivePath } = resolveLearningsPaths(repoRoot);
153
199
  if (!existsSync(filePath)) return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
154
- const archivePath = path.join(
155
- repoRoot,
156
- '.orchestrator',
157
- 'metrics',
158
- 'learnings-archive.jsonl',
159
- );
160
200
  const res = await sweepExpiredLearnings({ filePath, archivePath, dryRun: true });
161
201
  if (!res || res.archived === 0) {
162
202
  return mkSkip(phase, `nothing archive-eligible (scanned=${res?.scanned ?? 0})`, 'sweep-dry-run');
@@ -168,36 +208,6 @@ async function decideExpiredSweep({ repoRoot }) {
168
208
  }
169
209
  }
170
210
 
171
- /**
172
- * 3.6.5 Auto-Dream nudge (#502). Config gate (kill-switch threshold=0, platform)
173
- * → then `shouldDispatchAutoDream` (trigger:false branches → skip).
174
- */
175
- async function decideAutoDream({ repoRoot, cfg, platform, memoryDir }) {
176
- const phase = '3.6.5';
177
- try {
178
- const threshold = cfg?.['memory-cleanup-threshold'] ?? 5;
179
- if (threshold === 0) {
180
- return mkSkip(phase, 'kill-switch (memory-cleanup-threshold=0)', 'config-gate');
181
- }
182
- if (!isClaudePlatform(platform)) {
183
- return mkSkip(phase, 'non-Claude-Code platform (memory dir unavailable)', 'config-gate');
184
- }
185
- // #1071: same root as the `repoRoot` handed to shouldDispatchAutoDream below —
186
- // a cwd-derived memory dir made the two halves of this decision disagree.
187
- const dir = memoryDir ?? resolveMemoryDir(repoRoot);
188
- const dec = await shouldDispatchAutoDream({
189
- repoRoot,
190
- memoryDir: dir,
191
- threshold,
192
- softLimit: cfg?.['memory-cleanup-soft-limit'] ?? 180,
193
- });
194
- if (!dec.trigger) return mkSkip(phase, dec.reason, 'auto-dream-signal');
195
- return mkRun(phase, dec.reason, 'auto-dream-signal');
196
- } catch (err) {
197
- return mkProbeError(phase, err);
198
- }
199
- }
200
-
201
211
  /**
202
212
  * 3.6.6 Skill-Applied Judge (#645 L3). Config gate (judge default OFF,
203
213
  * persistence) → then the judged-set emptiness check via
@@ -240,25 +250,6 @@ async function decideSkillJudge({ repoRoot, cfg, sessionId }) {
240
250
  }
241
251
  }
242
252
 
243
- /**
244
- * 3.6.7 Auto-Dialectic nudge (#506). Config gate (persistence, kill-switch
245
- * cadence=0) → then `shouldDispatchAutoDialectic` (the reference implementation
246
- * of the no-new-input-since-last-run pattern via `.orchestrator/dialectic-last-run`).
247
- */
248
- async function decideAutoDialectic({ repoRoot, cfg }) {
249
- const phase = '3.6.7';
250
- try {
251
- if (isPersistenceOff(cfg)) return mkSkip(phase, 'persistence=false', 'config-gate');
252
- const cadence = cfg?.dialectic?.cadence ?? 5;
253
- if (cadence === 0) return mkSkip(phase, 'kill-switch (dialectic.cadence=0)', 'config-gate');
254
- const dec = await shouldDispatchAutoDialectic({ repoRoot, cadence });
255
- if (!dec.trigger) return mkSkip(phase, dec.reason, 'auto-dialectic-signal');
256
- return mkRun(phase, dec.reason, 'auto-dialectic-signal');
257
- } catch (err) {
258
- return mkProbeError(phase, err);
259
- }
260
- }
261
-
262
253
  /**
263
254
  * 3.6.8 Reconciliation Rule Proposals (#696). Config gate (persistence,
264
255
  * reconcile.enabled default OFF, learnings.jsonl present) → then `runReconcile`
@@ -354,37 +345,45 @@ export function buildSkippedReport(plan) {
354
345
  // ---------------------------------------------------------------------------
355
346
 
356
347
  /**
357
- * Compute the run/skip plan for the six session-end Phase 3.6.x tail phases.
348
+ * Compute the run/skip plan for the six session-end Phase 3.6.x tail phase ids
349
+ * — four decided from live signals, two permanently retired (see the module
350
+ * header). The plan keeps all six entries, in id order, so readers that index
351
+ * by phase id are unaffected by the retirement.
358
352
  *
359
- * NEVER throws — each phase probe fail-opens to `run: true`, and the top-level
360
- * guard converts any unexpected error into an all-run plan so the coordinator
361
- * runs the full tail rather than silently skipping it.
353
+ * NEVER throws — each live phase probe fail-opens to `run: true`, and the
354
+ * top-level guard converts any unexpected error into an all-run plan so the
355
+ * coordinator runs the full tail rather than silently skipping it. The two
356
+ * retired phases stay `run: false` even on that error path: fail-open exists to
357
+ * avoid LOSING a phase, and a retired phase has no procedure left to lose.
362
358
  *
363
359
  * @param {object} args
364
360
  * @param {string} args.repoRoot Absolute repo root.
365
361
  * @param {object} args.config Parsed Session Config object (from parse-config.mjs).
366
362
  * @param {string|null} [args.sessionId] Current session id (for the 3.6.6 judged-set filter).
367
- * @param {string} [args.platform] Platform id ('claude' | 'codex' | 'cursor' | …).
368
- * @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir(repoRoot)).
363
+ * @param {string} [args.platform] ACCEPTED AND IGNORED since the 3.6.5 retirement
364
+ * (2026-09-09) — the platform gate existed only for the Auto-Dream memory dir.
365
+ * Kept in the signature because live callers still pass it.
366
+ * @param {string} [args.memoryDir] ACCEPTED AND IGNORED, same reason as `platform`.
369
367
  * @returns {Promise<TailPlan>}
370
368
  */
371
- export async function planTailPhases({ repoRoot, config, sessionId, platform, memoryDir } = {}) {
369
+ export async function planTailPhases({ repoRoot, config, sessionId } = {}) {
372
370
  const cfg = config ?? {};
373
371
  try {
374
372
  const plan = await Promise.all([
375
373
  decideMemoryProposals({ repoRoot, cfg }),
376
374
  decideExpiredSweep({ repoRoot }),
377
- decideAutoDream({ repoRoot, cfg, platform, memoryDir }),
375
+ mkRetired('3.6.5'),
378
376
  decideSkillJudge({ repoRoot, cfg, sessionId }),
379
- decideAutoDialectic({ repoRoot, cfg }),
377
+ mkRetired('3.6.7'),
380
378
  decideReconcile({ repoRoot, cfg }),
381
379
  ]);
382
380
  return { plan, skippedReport: buildSkippedReport(plan) };
383
381
  } catch (err) {
384
- // Top-level fail-open guard: run the full tail rather than lose it silently.
382
+ // Top-level fail-open guard: run the full LIVE tail rather than lose it
383
+ // silently. Retired phases are never resurrected by an error.
385
384
  const msg = err && err.message ? err.message : String(err);
386
385
  const plan = ['3.6.3', '3.6.4', '3.6.5', '3.6.6', '3.6.7', '3.6.8'].map((p) =>
387
- decision(p, true, `probe-error: ${msg}`, 'probe-error'),
386
+ RETIRED_PHASES.has(p) ? mkRetired(p) : decision(p, true, `probe-error: ${msg}`, 'probe-error'),
388
387
  );
389
388
  return { plan, skippedReport: buildSkippedReport(plan) };
390
389
  }
@@ -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
+ }
@@ -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
@@ -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 (