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
@@ -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
- # Issue #174 (Sub-Epic #161 Owner Persona Layer). Schema reference:
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. 1-100 characters.
11
+ # Required. Display name.
15
12
  name: Your Name
16
13
 
17
- # Optional. SHA256 hex digest of your email address. Used for cross-host
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
- # How direct should responses be. One of: direct | neutral | friendly.
28
- # Default: neutral.
18
+ # Required. One of: direct | neutral | friendly.
29
19
  style: direct
30
20
 
31
- # Optional free-form descriptor. Up to 200 chars. Examples:
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, why-use-many-token-when-few-do-trick.
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 (ECC/teaching mode).
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 (Sub-Epic C+E).
50
- # When true, the plugin may export anonymized hardware patterns (e.g.
51
- # "Mac M1 16GB freezes at 8 parallel agents"). Default: false (off).
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
- # 32-byte hex salt (64 chars), per-host. Used to derive a stable but
55
- # anonymous host-class identifier. Generated on first bootstrap when
56
- # hardware-sharing.enabled is set to true.
57
- hash-salt: null
58
-
59
- defaults:
60
- # Optional. Override the project's `test-command` from CLAUDE.md (or
61
- # AGENTS.md on Codex CLI) when no repo-local override exists. Up to 200
62
- # chars. See skills/_shared/instruction-file-resolution.md for the alias rule.
63
- preferred-test-command: null
64
-
65
- # Optional. Editor command for "open in editor" actions. Up to 50 chars.
66
- preferred-editor: null
67
-
68
- metadata:
69
- # Auto-set by the writer on first save (D2 bootstrap).
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. Intentionally separate from `owner-config.mjs` (which
6
- * ships the richer D2-era schema with schema-version, metadata, and extended
7
- * fields). This module targets the simpler D1 surface defined in the #161 epic.
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-06 with
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 and the unrelated async homonym in `owner-config-loader.mjs`):
101
- * 9 call sites 8 in code, 1 in `skills/session-start/SKILL.md:1113` — and
102
- * `rg 'await\s+loadOwnerConfig'` over the same scope returns ZERO. Eight read
103
- * `loadOwnerConfig().config` (`hooks/on-session-start.mjs:639`,
104
- * `hooks/skill-invocation-telemetry.mjs:155`, `scripts/telemetry.mjs:81,130`,
105
- * `scripts/vault-mirror.mjs:444`, `scripts/lib/telemetry/sync.mjs:202,269`,
106
- * and the SKILL.md snippet); the ninth destructures the same sync return
107
- * (`scripts/lib/soul-resolve.mjs:123`). `writeOwnerConfig` is likewise sync at
108
- * its single call site, `scripts/lib/owner-interview.mjs:228`.
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 exactly the three ways THIS
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
- * Never throws.
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
- * Thin wrapper over {@link loadCommandsFromSessionConfigDetailed}; byte-identical
233
- * return value for every input, including every failure path. Callers that need
234
- * to tell a failed read from an empty config use the detailed variant.
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
  *