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
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maintenance-due-banner.mjs — ONE session-start probe for the whole
|
|
3
|
+
* maintenance loop.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists
|
|
6
|
+
*
|
|
7
|
+
* Measured 2026-09-09 across consumer repos: `orchestrator.evolve.completed`
|
|
8
|
+
* fired ZERO times, all 628 learnings were still active, the session-end
|
|
9
|
+
* auto-dialectic nudge recorded `decided: true` while nobody ever ran it, and
|
|
10
|
+
* memory-cleanup had run in 1 of 3 repos. The maintenance loop was not
|
|
11
|
+
* missing — it was ADVERTISED IN THREE PLACES nobody reads at a moment nobody
|
|
12
|
+
* acts: two session-end nudges (3.6.5 auto-dream, 3.6.7 auto-dialectic) fire
|
|
13
|
+
* while the operator is closing down, and the session-start `reconcile-nudge`
|
|
14
|
+
* probe spoke for one signal out of several.
|
|
15
|
+
*
|
|
16
|
+
* This probe replaces all three with a single reading at the ONE moment the
|
|
17
|
+
* operator can act on it — session start — recommending the one verb that
|
|
18
|
+
* runs the whole loop: `/session housekeeping`.
|
|
19
|
+
*
|
|
20
|
+
* ## Design constraints it is written against
|
|
21
|
+
*
|
|
22
|
+
* - **HR-101 (a signal may only warn if it is rare).** Nothing is due ⇒ the
|
|
23
|
+
* probe is SILENT. Six independent signals are ANDed with a cooldown, not
|
|
24
|
+
* ORed into a permanent warning: a repo that ran `/session housekeeping`
|
|
25
|
+
* within the last {@link HOUSEKEEPING_COOLDOWN_DAYS} days says nothing at
|
|
26
|
+
* all, because the operator already did the thing the banner would ask for.
|
|
27
|
+
* - **HR-106 (the banner reports what the rule judged).** Every DUE signal
|
|
28
|
+
* carries the number or the date that made it due — never a bare count.
|
|
29
|
+
* - **Three-state, never two.** A signal whose inputs cannot be read goes to
|
|
30
|
+
* `undeterminable`; it is never folded into "clean". Copied wholesale from
|
|
31
|
+
* `reconcile-nudge-banner.mjs`'s `never` vs `undeterminable` discipline.
|
|
32
|
+
* - **NO new Session Config key.** Every threshold below is an EXISTING key or
|
|
33
|
+
* an existing module constant. Same posture as `reconcile-nudge-banner.mjs`
|
|
34
|
+
* and `loop-readiness-banner.mjs`: an advisory banner needs no switch.
|
|
35
|
+
*
|
|
36
|
+
* ## The six signals
|
|
37
|
+
*
|
|
38
|
+
* | id | due when | source |
|
|
39
|
+
* |-------------------|-----------------------------------------------------------------------|--------|
|
|
40
|
+
* | `evolve` | no `orchestrator.evolve.completed` on record AND ≥20 active learnings | `events.jsonl` + `computeReconcileNudge` |
|
|
41
|
+
* | `sweep` | the dry-run expiry sweep would archive ≥1 entry | `learnings/expiry-sweep.mjs` |
|
|
42
|
+
* | `reconcile` | `computeReconcileNudge().nudge === true` | `reconcile-nudge-banner.mjs` (reused whole) |
|
|
43
|
+
* | `dialectic` | `shouldDispatchAutoDialectic().trigger === true` | `auto-dialectic.mjs` |
|
|
44
|
+
* | `memory-cleanup` | `shouldDispatchAutoDream().trigger === true` | `auto-dream.mjs` |
|
|
45
|
+
* | `pending-sidecar` | a pending dream/dialectic proposal younger than 14 days is unapplied | `.orchestrator/*-pending*.md` |
|
|
46
|
+
*
|
|
47
|
+
* Only SIDE-EFFECT-FREE signal functions are called — never a variant that
|
|
48
|
+
* advances `.orchestrator/dialectic-last-run`, because a probe that writes the
|
|
49
|
+
* stamp would consume the very signal it reports. (The former recording wrapper
|
|
50
|
+
* around this signal was removed in #1288; only the pure decision function
|
|
51
|
+
* remains.)
|
|
52
|
+
*
|
|
53
|
+
* Never throws. `computeMaintenanceDue` always returns the full shape;
|
|
54
|
+
* `checkMaintenanceDue` returns the banner object or `null`.
|
|
55
|
+
*
|
|
56
|
+
* @module scripts/lib/maintenance-due-banner
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
60
|
+
import path from 'node:path';
|
|
61
|
+
|
|
62
|
+
import { computeReconcileNudge } from './reconcile-nudge-banner.mjs';
|
|
63
|
+
import { sweepExpiredLearnings } from './learnings/expiry-sweep.mjs';
|
|
64
|
+
import { shouldDispatchAutoDialectic } from './auto-dialectic.mjs';
|
|
65
|
+
import { shouldDispatchAutoDream } from './auto-dream.mjs';
|
|
66
|
+
import { resolveMemoryDir } from './memory-paths.mjs';
|
|
67
|
+
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
68
|
+
import { filterRealSessions } from './session-schema.mjs';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How many signals this probe knows about. The banner denominator is this
|
|
72
|
+
* number MINUS the signals a kill-switch skipped on this host — see the
|
|
73
|
+
* `skipped` array in {@link computeMaintenanceDue}'s result.
|
|
74
|
+
*/
|
|
75
|
+
export const MAINTENANCE_TOTAL_SIGNALS = 6;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Active-learning floor for the `evolve` signal. Deliberately the SAME number
|
|
79
|
+
* as `NUDGE_MIN_LEARNINGS` in `reconcile-nudge-banner.mjs` (20) — both answer
|
|
80
|
+
* "is the corpus large enough that never having processed it is a finding?".
|
|
81
|
+
* Defined locally rather than imported so the two can diverge if the reconcile
|
|
82
|
+
* threshold is ever retuned for reconcile-specific reasons.
|
|
83
|
+
*/
|
|
84
|
+
export const MAINTENANCE_MIN_LEARNINGS = 20;
|
|
85
|
+
|
|
86
|
+
/** Suppress the banner when a housekeeping session completed this recently. */
|
|
87
|
+
export const HOUSEKEEPING_COOLDOWN_DAYS = 7;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A pending sidecar older than this is not a nudge, it is archaeology — the
|
|
91
|
+
* operator has demonstrably moved on, and re-raising it every session start is
|
|
92
|
+
* the HR-101 failure mode.
|
|
93
|
+
*/
|
|
94
|
+
export const SIDECAR_MAX_AGE_DAYS = 14;
|
|
95
|
+
|
|
96
|
+
const MS_PER_DAY = 86_400_000;
|
|
97
|
+
|
|
98
|
+
/** The event name whose ABSENCE is the `evolve` signal. */
|
|
99
|
+
const EVOLVE_EVENT = 'orchestrator.evolve.completed';
|
|
100
|
+
|
|
101
|
+
/** Pending-proposal sidecars, repo-relative (owned by auto-dream / auto-dialectic). */
|
|
102
|
+
const PENDING_SIDECARS = ['.orchestrator/pending-dream.md', '.orchestrator/dialectic-pending.md'];
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Small readers — every one of them is never-throw and three-state
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** @param {unknown} now @returns {number} */
|
|
109
|
+
function nowMs(now) {
|
|
110
|
+
if (now instanceof Date) return now.getTime();
|
|
111
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
112
|
+
const parsed = typeof now === 'string' ? Date.parse(now) : NaN;
|
|
113
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** ISO timestamp → `YYYY-MM-DD`, or null. */
|
|
117
|
+
function isoDay(ts) {
|
|
118
|
+
return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Backwards-scan chunk size (#1290 item 2).
|
|
123
|
+
*
|
|
124
|
+
* NAMED CEILING: 256 KiB is ~800 records in this repo's ledger, so the common
|
|
125
|
+
* case — a repo that ran /evolve within its recent history — answers after a
|
|
126
|
+
* handful of reads instead of loading the whole 7.9 MB file. The scan is
|
|
127
|
+
* UNBOUNDED in the worst case ON PURPOSE: "never ran" is a claim about every
|
|
128
|
+
* line and cannot be made from a tail, so a repo with no `evolve.completed`
|
|
129
|
+
* record still walks the file to its start — just in chunks, never all at once
|
|
130
|
+
* in one string.
|
|
131
|
+
*
|
|
132
|
+
* REVISIT TRIGGER: the maintenance probe's median passes 1000 ms (half
|
|
133
|
+
* `PROBE_BUDGET_MS`), or one repo's `events.jsonl` passes 50 MB. Either means
|
|
134
|
+
* the "never ran" walk has become the cost that matters and the answer needs an
|
|
135
|
+
* index rather than a scan.
|
|
136
|
+
*/
|
|
137
|
+
export const TAIL_CHUNK_BYTES = 256 * 1024;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Scan a buffer of COMPLETE lines backwards for the newest evolve record.
|
|
141
|
+
*
|
|
142
|
+
* @param {Buffer} buf
|
|
143
|
+
* @returns {{lastAt: string|null}|null} null ⇒ no record in this buffer
|
|
144
|
+
*/
|
|
145
|
+
function scanEvolveLines(buf) {
|
|
146
|
+
if (buf.length === 0) return null;
|
|
147
|
+
const lines = buf.toString('utf8').split('\n');
|
|
148
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
if (!line || !line.includes(EVOLVE_EVENT)) continue; // cheap pre-filter before JSON.parse
|
|
151
|
+
try {
|
|
152
|
+
const rec = JSON.parse(line);
|
|
153
|
+
if (rec?.event !== EVOLVE_EVENT) continue;
|
|
154
|
+
return { lastAt: typeof rec.timestamp === 'string' ? rec.timestamp : null };
|
|
155
|
+
} catch {
|
|
156
|
+
continue; // a malformed line is not evidence either way — keep scanning
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find the most recent `orchestrator.evolve.completed` record.
|
|
164
|
+
*
|
|
165
|
+
* Reads the ledger BACKWARDS in {@link TAIL_CHUNK_BYTES} chunks and stops at
|
|
166
|
+
* the first hit, because the interesting answer is the LAST occurrence. The
|
|
167
|
+
* former implementation `readFileSync`-ed the whole file (7.9 MB here, 30–44 ms)
|
|
168
|
+
* to answer a question the last few kilobytes usually settle.
|
|
169
|
+
*
|
|
170
|
+
* The one bug a naive chunked scan introduces is a record SPLIT across a chunk
|
|
171
|
+
* boundary: the bytes before the first newline of a chunk are the tail of a line
|
|
172
|
+
* whose head is in the chunk not read yet, so they are CARRIED, never parsed
|
|
173
|
+
* here. Splitting on the 0x0A byte is safe on UTF-8 — no continuation byte can
|
|
174
|
+
* equal a newline — so a multibyte character never splits a line either.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} repoRoot
|
|
177
|
+
* @returns {{ok: boolean, lastAt: string|null}} `ok: false` ⇒ the ledger exists
|
|
178
|
+
* but could not be read — the caller must record `undeterminable`, never clean.
|
|
179
|
+
*/
|
|
180
|
+
function readLastEvolveRun(repoRoot) {
|
|
181
|
+
const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
182
|
+
if (!existsSync(file)) return { ok: true, lastAt: null }; // fresh repo: genuinely never
|
|
183
|
+
let fd;
|
|
184
|
+
try {
|
|
185
|
+
fd = openSync(file, 'r');
|
|
186
|
+
let pos = fstatSync(fd).size;
|
|
187
|
+
/** Partial line at the FRONT of everything read so far. */
|
|
188
|
+
let carry = Buffer.alloc(0);
|
|
189
|
+
|
|
190
|
+
while (pos > 0) {
|
|
191
|
+
const length = Math.min(TAIL_CHUNK_BYTES, pos);
|
|
192
|
+
pos -= length;
|
|
193
|
+
const buf = Buffer.alloc(length);
|
|
194
|
+
readSync(fd, buf, 0, length, pos);
|
|
195
|
+
const block = carry.length > 0 ? Buffer.concat([buf, carry]) : buf;
|
|
196
|
+
const firstNewline = block.indexOf(0x0a);
|
|
197
|
+
if (firstNewline === -1) {
|
|
198
|
+
carry = block; // no complete line yet — a line longer than one chunk
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const hit = scanEvolveLines(block.subarray(firstNewline + 1));
|
|
202
|
+
if (hit) return { ok: true, lastAt: hit.lastAt };
|
|
203
|
+
carry = block.subarray(0, firstNewline);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// pos === 0: the carry is the file's FIRST line, complete by construction.
|
|
207
|
+
const hit = scanEvolveLines(carry);
|
|
208
|
+
return { ok: true, lastAt: hit ? hit.lastAt : null };
|
|
209
|
+
} catch {
|
|
210
|
+
return { ok: false, lastAt: null };
|
|
211
|
+
} finally {
|
|
212
|
+
if (fd !== undefined) {
|
|
213
|
+
try {
|
|
214
|
+
closeSync(fd);
|
|
215
|
+
} catch {
|
|
216
|
+
/* best-effort */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Most recent COMPLETED housekeeping session, or null.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} repoRoot
|
|
226
|
+
* @returns {string|null} ISO timestamp
|
|
227
|
+
*/
|
|
228
|
+
function readLastHousekeeping(repoRoot) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = filterRealSessions(readCanonicalSessions({ repoRoot }));
|
|
231
|
+
let max = null;
|
|
232
|
+
for (const e of entries) {
|
|
233
|
+
if (e?.session_type !== 'housekeeping') continue;
|
|
234
|
+
const ts = e.completed_at;
|
|
235
|
+
if (typeof ts !== 'string' || ts.length === 0) continue;
|
|
236
|
+
if (max === null || ts > max) max = ts;
|
|
237
|
+
}
|
|
238
|
+
return max;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Claude-Code-only memory dir gate — mirrors `session-end/phase-skip.mjs`. */
|
|
245
|
+
function isClaudePlatform(platform) {
|
|
246
|
+
if (platform === undefined || platform === null || platform === '') return true;
|
|
247
|
+
const p = String(platform).toLowerCase();
|
|
248
|
+
return p === 'claude' || p === 'claude-code' || p === 'claudecode';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Computation
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Compute the maintenance-due reading. Pure with respect to the repo: reads
|
|
257
|
+
* only, writes nothing, advances no last-run stamp. Never throws.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} [opts]
|
|
260
|
+
* @param {string} [opts.repoRoot] — defaults to `process.cwd()`.
|
|
261
|
+
* @param {object} [opts.config] — parsed Session Config (thresholds only).
|
|
262
|
+
* @param {Date|number} [opts.now] — injectable clock.
|
|
263
|
+
* @param {string} [opts.platform] — harness platform; non-Claude skips `memory-cleanup`.
|
|
264
|
+
* @returns {Promise<{
|
|
265
|
+
* due: Array<{id: string, detail: string}>,
|
|
266
|
+
* total: number,
|
|
267
|
+
* skipped: string[],
|
|
268
|
+
* undeterminable: string[],
|
|
269
|
+
* lastHousekeeping: string|null,
|
|
270
|
+
* }>}
|
|
271
|
+
*/
|
|
272
|
+
export async function computeMaintenanceDue(opts = {}) {
|
|
273
|
+
const repoRoot =
|
|
274
|
+
typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0 ? opts.repoRoot : process.cwd();
|
|
275
|
+
const config = opts.config ?? {};
|
|
276
|
+
const now = nowMs(opts.now);
|
|
277
|
+
|
|
278
|
+
/** @type {Array<{id: string, detail: string}>} */
|
|
279
|
+
const due = [];
|
|
280
|
+
/** @type {string[]} */
|
|
281
|
+
const undeterminable = [];
|
|
282
|
+
/**
|
|
283
|
+
* Signals a kill-switch turned OFF for this host. They are neither due nor
|
|
284
|
+
* undeterminable — they were never judged, so they must not appear in the
|
|
285
|
+
* denominator either (HR-106: the banner reports what the rule judged).
|
|
286
|
+
* @type {string[]}
|
|
287
|
+
*/
|
|
288
|
+
const skipped = [];
|
|
289
|
+
const markDue = (id, detail) => due.push({ id, detail });
|
|
290
|
+
|
|
291
|
+
// --- reconcile (S3) + the active-learning count S1 needs ------------------
|
|
292
|
+
// ONE call serves both signals: re-deriving the active-learning filter here
|
|
293
|
+
// would be a second, drifting copy of the reconcile thresholds (BV-001.2).
|
|
294
|
+
let nudge;
|
|
295
|
+
try {
|
|
296
|
+
nudge = await computeReconcileNudge({ repoRoot, now: opts.now });
|
|
297
|
+
} catch {
|
|
298
|
+
/* left undefined — the two signals below become `undeterminable`, not clean */
|
|
299
|
+
}
|
|
300
|
+
if (!nudge) {
|
|
301
|
+
undeterminable.push('evolve', 'reconcile');
|
|
302
|
+
} else {
|
|
303
|
+
// --- evolve (S1) -------------------------------------------------------
|
|
304
|
+
const evolve = readLastEvolveRun(repoRoot);
|
|
305
|
+
if (!evolve.ok) {
|
|
306
|
+
undeterminable.push('evolve');
|
|
307
|
+
} else if (evolve.lastAt === null && nudge.activeLearnings >= MAINTENANCE_MIN_LEARNINGS) {
|
|
308
|
+
markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- reconcile (S3) ----------------------------------------------------
|
|
312
|
+
if (nudge.nudge === true) {
|
|
313
|
+
markDue('reconcile', isoDay(nudge.lastRunAt) ?? 'never');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- sweep (S2) ----------------------------------------------------------
|
|
318
|
+
try {
|
|
319
|
+
const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
|
|
320
|
+
const res = await sweepExpiredLearnings({
|
|
321
|
+
filePath: path.join(metrics, 'learnings.jsonl'),
|
|
322
|
+
archivePath: path.join(metrics, 'learnings-archive.jsonl'),
|
|
323
|
+
now: opts.now,
|
|
324
|
+
dryRun: true,
|
|
325
|
+
});
|
|
326
|
+
if (Number(res?.archived) > 0) markDue('sweep', `${res.archived} expired`);
|
|
327
|
+
} catch {
|
|
328
|
+
undeterminable.push('sweep');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- dialectic (S4) ------------------------------------------------------
|
|
332
|
+
const cadence = config?.dialectic?.cadence ?? 5;
|
|
333
|
+
if (cadence === 0) {
|
|
334
|
+
skipped.push('dialectic');
|
|
335
|
+
} else {
|
|
336
|
+
try {
|
|
337
|
+
const dec = await shouldDispatchAutoDialectic({ repoRoot, cadence });
|
|
338
|
+
if (dec?.trigger === true) {
|
|
339
|
+
markDue('dialectic', isoDay(dec.signals?.lastRunAt) ?? 'never');
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
undeterminable.push('dialectic');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- memory-cleanup (S5) -------------------------------------------------
|
|
347
|
+
const threshold = config?.['memory-cleanup-threshold'] ?? 5;
|
|
348
|
+
if (threshold === 0 || !isClaudePlatform(opts.platform)) {
|
|
349
|
+
skipped.push('memory-cleanup');
|
|
350
|
+
} else {
|
|
351
|
+
try {
|
|
352
|
+
const dec = await shouldDispatchAutoDream({
|
|
353
|
+
repoRoot,
|
|
354
|
+
memoryDir: resolveMemoryDir(repoRoot),
|
|
355
|
+
threshold,
|
|
356
|
+
softLimit: config?.['memory-cleanup-soft-limit'] ?? 180,
|
|
357
|
+
});
|
|
358
|
+
if (dec?.trigger === true) {
|
|
359
|
+
const s = dec.signals ?? {};
|
|
360
|
+
markDue(
|
|
361
|
+
'memory-cleanup',
|
|
362
|
+
isoDay(s.lastCleanupAt) ?? `never, ${s.sessionsSinceCleanup ?? 0} sessions`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
undeterminable.push('memory-cleanup');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- pending sidecar (S6) ------------------------------------------------
|
|
371
|
+
try {
|
|
372
|
+
const pending = [];
|
|
373
|
+
for (const rel of PENDING_SIDECARS) {
|
|
374
|
+
const file = path.join(repoRoot, rel);
|
|
375
|
+
if (!existsSync(file)) continue;
|
|
376
|
+
const ageDays = (now - statSync(file).mtimeMs) / MS_PER_DAY;
|
|
377
|
+
if (ageDays <= SIDECAR_MAX_AGE_DAYS) pending.push(path.basename(rel));
|
|
378
|
+
}
|
|
379
|
+
if (pending.length > 0) markDue('pending-sidecar', pending.join(', '));
|
|
380
|
+
} catch {
|
|
381
|
+
undeterminable.push('pending-sidecar');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
due,
|
|
386
|
+
// HR-106: the denominator counts the signals this host actually EVALUATED.
|
|
387
|
+
// A signal a kill-switch turned off was never judged, so reporting it in
|
|
388
|
+
// "3 of 6" would quote a denominator the rule never used.
|
|
389
|
+
total: MAINTENANCE_TOTAL_SIGNALS - skipped.length,
|
|
390
|
+
skipped,
|
|
391
|
+
undeterminable,
|
|
392
|
+
lastHousekeeping: readLastHousekeeping(repoRoot),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Session-start probe entry point.
|
|
398
|
+
*
|
|
399
|
+
* Silent (`null`) when nothing is due and everything was readable, and ALSO
|
|
400
|
+
* when a housekeeping session completed within {@link HOUSEKEEPING_COOLDOWN_DAYS}
|
|
401
|
+
* days — the cooldown is what keeps this instrument rare (HR-101). The
|
|
402
|
+
* computation still runs and is still available via {@link computeMaintenanceDue};
|
|
403
|
+
* only the banner is suppressed.
|
|
404
|
+
*
|
|
405
|
+
* @param {{repoRoot?: string, config?: object, now?: Date|number, platform?: string}} [opts]
|
|
406
|
+
* @returns {Promise<null | {severity: 'warn', message: string}>}
|
|
407
|
+
*/
|
|
408
|
+
export async function checkMaintenanceDue(opts = {}) {
|
|
409
|
+
try {
|
|
410
|
+
const computed = await computeMaintenanceDue(opts);
|
|
411
|
+
if (computed.due.length === 0 && computed.undeterminable.length === 0) return null;
|
|
412
|
+
|
|
413
|
+
// Cooldown — no new config key: the housekeeping session's own record IS
|
|
414
|
+
// the "last maintenance run" stamp.
|
|
415
|
+
if (computed.lastHousekeeping !== null) {
|
|
416
|
+
const ageMs = nowMs(opts.now) - Date.parse(computed.lastHousekeeping);
|
|
417
|
+
if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < HOUSEKEEPING_COOLDOWN_DAYS * MS_PER_DAY) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// HR-106: every number in the banner is a number the verdict was computed
|
|
423
|
+
// from — no aggregate stands alone without the signal that produced it.
|
|
424
|
+
const detail = computed.due.map((d) => `${d.id}: ${d.detail}`).join(' · ');
|
|
425
|
+
const unknown =
|
|
426
|
+
computed.undeterminable.length > 0
|
|
427
|
+
? ` · undeterminable: ${computed.undeterminable.join(', ')}`
|
|
428
|
+
: '';
|
|
429
|
+
const message =
|
|
430
|
+
`⚠ maintenance due: ${computed.due.length} of ${computed.total}` +
|
|
431
|
+
(detail ? ` (${detail})` : '') +
|
|
432
|
+
unknown +
|
|
433
|
+
' — run /session housekeeping.';
|
|
434
|
+
|
|
435
|
+
return { severity: 'warn', message };
|
|
436
|
+
} catch {
|
|
437
|
+
// Defensive catch-all — a session-start banner must never throw.
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -4,69 +4,52 @@
|
|
|
4
4
|
# ~/.config/session-orchestrator/owner.yaml (or $XDG_CONFIG_HOME/...) and
|
|
5
5
|
# fill in your values. NEVER commit this file to a project repo.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
# docs/owner-config-schema.md.
|
|
9
|
-
|
|
10
|
-
# Schema version. MUST be 1. The plugin refuses to load mismatched versions.
|
|
11
|
-
schema-version: 1
|
|
7
|
+
# SSOT is code, not this file: scripts/lib/owner-yaml.mjs (schema, validator,
|
|
8
|
+
# loader, writer). Doc cross-check: docs/owner-config-schema.md.
|
|
12
9
|
|
|
13
10
|
owner:
|
|
14
|
-
# Required. Display name.
|
|
11
|
+
# Required. Display name.
|
|
15
12
|
name: Your Name
|
|
16
13
|
|
|
17
|
-
#
|
|
18
|
-
# identity correlation when sharing learnings (D4/C4). 64 hex chars exactly,
|
|
19
|
-
# or null to opt out. Plugin can compute this for you on first bootstrap.
|
|
20
|
-
email-hash: null
|
|
21
|
-
|
|
22
|
-
# Required. ISO-639-1 language code (e.g. "de", "en"). Optionally with a
|
|
23
|
-
# region tag like "en-US". Drives default response language in soul.md.
|
|
14
|
+
# Required. ISO-639-1 language code — only "de" or "en" are valid.
|
|
24
15
|
language: en
|
|
25
16
|
|
|
26
17
|
tone:
|
|
27
|
-
#
|
|
28
|
-
# Default: neutral.
|
|
18
|
+
# Required. One of: direct | neutral | friendly.
|
|
29
19
|
style: direct
|
|
30
20
|
|
|
31
|
-
# Optional free-form descriptor.
|
|
32
|
-
# "austrian-pragmatic", "british-dry", "minimal-comments".
|
|
21
|
+
# Optional free-form descriptor, e.g. "austrian-pragmatic", "british-dry".
|
|
33
22
|
tonality: austrian-pragmatic
|
|
34
23
|
|
|
35
24
|
efficiency:
|
|
36
|
-
# Caveman-inspired output dial. One of: lite | full | ultra.
|
|
37
|
-
# lite — minimum tokens, telegram style
|
|
25
|
+
# Required. Caveman-inspired output dial. One of: lite | full | ultra.
|
|
26
|
+
# lite — minimum tokens, telegram style.
|
|
38
27
|
# full — default, sentence-style narration.
|
|
39
|
-
# ultra — full + extra context, narrative tone
|
|
28
|
+
# ultra — full + extra context, narrative tone.
|
|
40
29
|
output-level: full
|
|
41
30
|
|
|
42
|
-
# Pre-tool-call narration intensity. One of: minimal | verbose.
|
|
31
|
+
# Required. Pre-tool-call narration intensity. One of: minimal | verbose.
|
|
43
32
|
preamble: minimal
|
|
44
33
|
|
|
45
|
-
# Inline code comments. One of: minimal | full.
|
|
46
|
-
comments-in-code: minimal
|
|
47
|
-
|
|
48
34
|
hardware-sharing:
|
|
49
|
-
# Consent gate for sharing hardware-pattern learnings
|
|
50
|
-
#
|
|
51
|
-
#
|
|
35
|
+
# Required. Consent gate for sharing hardware-pattern learnings. When true,
|
|
36
|
+
# the plugin may export anonymized hardware patterns (e.g. "Mac M1 16GB
|
|
37
|
+
# freezes at 8 parallel agents"). Default: false (off).
|
|
52
38
|
enabled: false
|
|
53
39
|
|
|
54
|
-
#
|
|
55
|
-
# anonymous host-class identifier.
|
|
56
|
-
#
|
|
57
|
-
hash-salt:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
created_at: null
|
|
71
|
-
# Auto-bumped on every successful save.
|
|
72
|
-
updated_at: null
|
|
40
|
+
# Required (non-empty) only when enabled: true above. Per-host salt used to
|
|
41
|
+
# derive a stable but anonymous host-class identifier. Leave empty while
|
|
42
|
+
# enabled: false — the plugin can generate one for you on opt-in.
|
|
43
|
+
hash-salt: ''
|
|
44
|
+
|
|
45
|
+
# Optional. Host-local path overrides (#653). Uncomment and set any of these
|
|
46
|
+
# to override the committed repo-level defaults on THIS host only.
|
|
47
|
+
# paths:
|
|
48
|
+
# vault-dir: '' # '' = no override
|
|
49
|
+
# baseline-path: '' # '' = no override
|
|
50
|
+
# namespace-map-path: '' # '' = no override (#725 D5)
|
|
51
|
+
# confidential-names-file: '' # '' = no override (#728a)
|
|
52
|
+
|
|
53
|
+
# Optional. Host-local cross-repo dispatcher autonomy override (#679).
|
|
54
|
+
# dispatcher:
|
|
55
|
+
# autonomy: '' # '' = no override; one of off | advisory | autonomous-gated
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* owner-yaml.mjs — owner.yaml schema, validator, parser, writer (Issue #161, D1).
|
|
3
3
|
*
|
|
4
4
|
* Implements the public Owner Persona Layer API: schema validation, disk I/O,
|
|
5
|
-
* and sensible defaults.
|
|
6
|
-
*
|
|
7
|
-
* fields)
|
|
5
|
+
* and sensible defaults. This module IS the live owner-config surface — the
|
|
6
|
+
* richer D2-era schema (`owner-config.mjs`, with `schema-version`, `metadata`,
|
|
7
|
+
* and extended fields) was removed on 2026-09-09; this module's D1 surface
|
|
8
|
+
* from the #161 epic is now the only one.
|
|
8
9
|
*
|
|
9
10
|
* ── Schema (schema-version: 1) ───────────────────────────────────────────────
|
|
10
11
|
*
|
|
@@ -95,17 +96,17 @@ let _yamlWarned = false;
|
|
|
95
96
|
* Resolve `js-yaml` at CALL time instead of at import time (GH#62/#63).
|
|
96
97
|
*
|
|
97
98
|
* WHY `createRequire` and not `await import('js-yaml')`: every caller of
|
|
98
|
-
* {@link loadOwnerConfig} consumes it SYNCHRONOUSLY. Measured 2026-09-
|
|
99
|
+
* {@link loadOwnerConfig} consumes it SYNCHRONOUSLY. Measured 2026-09-09 with
|
|
99
100
|
* `rg -n --glob '!tests/**' 'loadOwnerConfig\(' scripts hooks skills` (minus
|
|
100
|
-
* this file
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `
|
|
105
|
-
* `scripts/
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
101
|
+
* this file itself — this is the sole loader now, no other async homonym
|
|
102
|
+
* exists): 8 call sites, all synchronous — `rg 'await\s+loadOwnerConfig'`
|
|
103
|
+
* over the same scope returns ZERO. All eight read `loadOwnerConfig().config`
|
|
104
|
+
* (`hooks/on-session-start.mjs:639`, `hooks/skill-invocation-telemetry.mjs:155`,
|
|
105
|
+
* `scripts/telemetry.mjs:81,130`, `scripts/vault-mirror.mjs:444`,
|
|
106
|
+
* `scripts/lib/telemetry/sync.mjs:513,643`, and
|
|
107
|
+
* `skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md:53`).
|
|
108
|
+
* `writeOwnerConfig` is likewise sync at its single call site,
|
|
109
|
+
* `scripts/lib/owner-interview.mjs:228`.
|
|
109
110
|
*
|
|
110
111
|
* Making either loader async would be a breaking change to all of them; a lazy
|
|
111
112
|
* `require()` keeps the sync contract and only fails at CALL time — where the
|
|
@@ -117,16 +117,18 @@ function resolveRepoRoot(explicit) {
|
|
|
117
117
|
* Closed set of `degraded` reasons for {@link loadCommandsFromSessionConfigDetailed}.
|
|
118
118
|
*
|
|
119
119
|
* Its own enum, deliberately not shared with `ci-status-banner.mjs`'s
|
|
120
|
-
* `DEGRADED_REASONS`: the members below are
|
|
120
|
+
* `DEGRADED_REASONS`: the members below are the ways THIS
|
|
121
121
|
* config-read can fail, and an enum whose members are not exhaustively
|
|
122
122
|
* reachable cannot be switched on exhaustively.
|
|
123
123
|
*
|
|
124
|
+
* - `invalid-repo-root` — no non-blank string root was supplied.
|
|
124
125
|
* - `script-missing` — `scripts/parse-config.mjs` is not on disk.
|
|
125
126
|
* - `spawn-failed` — the subprocess exited non-zero, timed out, or wrote
|
|
126
127
|
* nothing to stdout.
|
|
127
128
|
* - `parse-error` — stdout was not parseable JSON (or the read threw).
|
|
128
129
|
*/
|
|
129
130
|
export const CONFIG_READ_DEGRADED_REASONS = Object.freeze([
|
|
131
|
+
'invalid-repo-root',
|
|
130
132
|
'script-missing',
|
|
131
133
|
'spawn-failed',
|
|
132
134
|
'parse-error',
|
|
@@ -168,13 +170,18 @@ function sessionConfigFileExists(repoRoot) {
|
|
|
168
170
|
* `degraded` is OMITTED, not set to null, on the success path, so a strict
|
|
169
171
|
* `toEqual({commands: {…}})` pin holds for every readable config.
|
|
170
172
|
*
|
|
171
|
-
*
|
|
173
|
+
* Requires an explicit, non-blank repository root; callers that intend cwd
|
|
174
|
+
* must resolve that default before invoking this loader. Never throws.
|
|
172
175
|
*
|
|
173
176
|
* @param {string} repoRoot
|
|
174
177
|
* @returns {{commands: {lint?: string, typecheck?: string, test?: string},
|
|
175
|
-
* degraded?: 'script-missing'|'spawn-failed'|'parse-error'}}
|
|
178
|
+
* degraded?: 'invalid-repo-root'|'script-missing'|'spawn-failed'|'parse-error'}}
|
|
176
179
|
*/
|
|
177
180
|
export function loadCommandsFromSessionConfigDetailed(repoRoot) {
|
|
181
|
+
// Node inherits cwd for undefined/null, which would read another repo's config.
|
|
182
|
+
if (typeof repoRoot !== 'string' || !repoRoot.trim()) {
|
|
183
|
+
return { commands: {}, degraded: 'invalid-repo-root' };
|
|
184
|
+
}
|
|
178
185
|
try {
|
|
179
186
|
const scriptPath = join(
|
|
180
187
|
dirname(fileURLToPath(import.meta.url)),
|
|
@@ -229,9 +236,9 @@ export function loadCommandsFromSessionConfigDetailed(repoRoot) {
|
|
|
229
236
|
* Returns a partial object — keys that fail to resolve are simply absent
|
|
230
237
|
* (the caller falls through to DEFAULT_COMMANDS for those).
|
|
231
238
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
239
|
+
* Returns the commands half of {@link loadCommandsFromSessionConfigDetailed},
|
|
240
|
+
* including an empty object for an invalid root. Callers that need to tell a
|
|
241
|
+
* failed read from an empty config use the detailed variant.
|
|
235
242
|
*
|
|
236
243
|
* Never throws.
|
|
237
244
|
*
|