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.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +8 -8
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- 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
|
|
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
|
-
*
|
|
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' |
|
|
38
|
-
* 'skill-invocations.jsonl' | '
|
|
39
|
-
* '
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
360
|
-
* guard converts any unexpected error into an all-run plan so the
|
|
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]
|
|
368
|
-
*
|
|
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
|
|
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
|
-
|
|
375
|
+
mkRetired('3.6.5'),
|
|
378
376
|
decideSkillJudge({ repoRoot, cfg, sessionId }),
|
|
379
|
-
|
|
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
|
|
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 (
|