session-orchestrator 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -40,9 +40,9 @@ import path from 'node:path';
40
40
  import crypto from 'node:crypto';
41
41
 
42
42
  import { _parseStateMdLock } from '../config/state-md-lock.mjs';
43
- import { tryAcquireFileLock } from '../file-lock.mjs';
44
- import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
45
- import { nowIso, delay, parseLockBody } from './lock-body.mjs';
43
+ import { releaseFileLock, tryAcquireFileLock } from '../file-lock.mjs';
44
+ import { stableHostname } from '../host-identity.mjs';
45
+ import { nowIso, delay } from './lock-body.mjs';
46
46
 
47
47
  // ---------------------------------------------------------------------------
48
48
  // Constants
@@ -198,6 +198,9 @@ export async function acquireStateLock({
198
198
  * { ok: true } — lock unlinked
199
199
  * { ok: false, reason: 'not-found' } — no lock file exists
200
200
  * { ok: false, reason: 'not-owner' } — lock held by different holder/PID
201
+ * { ok: false, reason: 'busy' } — the shared `.acquire` guard
202
+ * stayed taken past its budget;
203
+ * the lock is left untouched
201
204
  * { ok: false, reason: 'fs-error', error } — filesystem failure
202
205
  *
203
206
  * Never throws.
@@ -210,42 +213,13 @@ export async function acquireStateLock({
210
213
  export function releaseStateLock({ repoRoot, sessionId, holder } = {}) {
211
214
  const lockFile = stateLockPathFor(repoRoot);
212
215
 
213
- let raw;
214
- try {
215
- raw = fs.readFileSync(lockFile, 'utf8');
216
- } catch (err) {
217
- if (err.code === 'ENOENT') {
218
- return { ok: false, reason: 'not-found' };
219
- }
220
- return { ok: false, reason: 'fs-error', error: err.message };
221
- }
222
-
223
- const lock = parseLockBody(raw);
224
- if (lock === null) {
225
- // Unparseable — refuse to delete; some other process may be writing now.
226
- return { ok: false, reason: 'not-owner' };
227
- }
228
-
229
- const expectedHolder = holder ?? sessionId ?? null;
230
- const ownerMatch = expectedHolder !== null
231
- ? lock.holder === expectedHolder
232
- // #1072: alias-aware host identity — a raw comparison strands the lock as
233
- // 'not-owner' once os.hostname() flips spelling mid-session.
234
- : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
235
-
236
- if (!ownerMatch) {
237
- return { ok: false, reason: 'not-owner' };
238
- }
239
-
240
- try {
241
- fs.unlinkSync(lockFile);
242
- return { ok: true };
243
- } catch (err) {
244
- if (err.code === 'ENOENT') {
245
- return { ok: false, reason: 'not-found' };
246
- }
247
- return { ok: false, reason: 'fs-error', error: err.message };
248
- }
216
+ // Consolidated onto releaseFileLock (#1349); the `pid` staleCheck makes the
217
+ // takeover race unreachable today — if this lock ever moves to a lease mode,
218
+ // the shared guard is what keeps release safe.
219
+ // `sessionId` is the legacy alias for `holder` (see the docblock); an empty
220
+ // expected holder falls through to releaseFileLock's PID + host owner check,
221
+ // exactly as the inline sequence did.
222
+ return releaseFileLock(lockFile, { holder: holder ?? sessionId ?? undefined });
249
223
  }
250
224
 
251
225
  /**
@@ -345,11 +319,15 @@ export async function withStateMdLock(repoRoot, fn, opts = {}) {
345
319
  caughtError = err;
346
320
  } finally {
347
321
  // Always release — even on fn() throw — so the lock does not leak.
348
- // Only WARN on fs-error: 'not-found' and 'not-owner' are recoverable race
349
- // conditions (someone else cleaned up our lock already safe to proceed).
322
+ // WARN only where OUR lock is left behind: 'fs-error' and 'busy' (the
323
+ // shared guard stayed taken #1349). 'not-found' and 'not-owner' are
324
+ // recoverable race conditions (someone else cleaned up our lock — already
325
+ // safe to proceed) and stay silent, as before.
350
326
  const releaseResult = releaseStateLock({ repoRoot, holder });
351
327
  if (!releaseResult.ok && releaseResult.reason === 'fs-error') {
352
328
  console.warn(`withStateMdLock: release failed (fs-error: ${releaseResult.error ?? 'unknown'})`);
329
+ } else if (!releaseResult.ok && releaseResult.reason === 'busy') {
330
+ console.warn('withStateMdLock: release failed (busy: acquire guard held) — state.lock left for its stale policy');
353
331
  }
354
332
  }
355
333
 
@@ -0,0 +1,450 @@
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
+ // HR-106: report what the rule JUDGED. `computeReconcileNudge` nudges on
314
+ // a BACKLOG (eligible-unmaterialized learnings), never on a date — so
315
+ // printing `lastRunAt` here put today's date next to the word "due" and
316
+ // read as "last run today and already due again". Measured 2026-09-09
317
+ // (learning 013a45ba): with 108 learnings capped under
318
+ // `max-proposals-per-run: 10`, the row stayed due after every run while
319
+ // showing that run's own date. `reasons` is the judgment itself.
320
+ const why = Array.isArray(nudge.reasons) && nudge.reasons.length > 0
321
+ ? nudge.reasons.join(', ')
322
+ : `last run ${isoDay(nudge.lastRunAt) ?? 'never'}`;
323
+ markDue('reconcile', why);
324
+ }
325
+ }
326
+
327
+ // --- sweep (S2) ----------------------------------------------------------
328
+ try {
329
+ const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
330
+ const res = await sweepExpiredLearnings({
331
+ filePath: path.join(metrics, 'learnings.jsonl'),
332
+ archivePath: path.join(metrics, 'learnings-archive.jsonl'),
333
+ now: opts.now,
334
+ dryRun: true,
335
+ });
336
+ if (Number(res?.archived) > 0) markDue('sweep', `${res.archived} expired`);
337
+ } catch {
338
+ undeterminable.push('sweep');
339
+ }
340
+
341
+ // --- dialectic (S4) ------------------------------------------------------
342
+ const cadence = config?.dialectic?.cadence ?? 5;
343
+ if (cadence === 0) {
344
+ skipped.push('dialectic');
345
+ } else {
346
+ try {
347
+ const dec = await shouldDispatchAutoDialectic({ repoRoot, cadence });
348
+ if (dec?.trigger === true) {
349
+ markDue('dialectic', isoDay(dec.signals?.lastRunAt) ?? 'never');
350
+ }
351
+ } catch {
352
+ undeterminable.push('dialectic');
353
+ }
354
+ }
355
+
356
+ // --- memory-cleanup (S5) -------------------------------------------------
357
+ const threshold = config?.['memory-cleanup-threshold'] ?? 5;
358
+ if (threshold === 0 || !isClaudePlatform(opts.platform)) {
359
+ skipped.push('memory-cleanup');
360
+ } else {
361
+ try {
362
+ const dec = await shouldDispatchAutoDream({
363
+ repoRoot,
364
+ memoryDir: resolveMemoryDir(repoRoot),
365
+ threshold,
366
+ softLimit: config?.['memory-cleanup-soft-limit'] ?? 180,
367
+ });
368
+ if (dec?.trigger === true) {
369
+ const s = dec.signals ?? {};
370
+ markDue(
371
+ 'memory-cleanup',
372
+ isoDay(s.lastCleanupAt) ?? `never, ${s.sessionsSinceCleanup ?? 0} sessions`,
373
+ );
374
+ }
375
+ } catch {
376
+ undeterminable.push('memory-cleanup');
377
+ }
378
+ }
379
+
380
+ // --- pending sidecar (S6) ------------------------------------------------
381
+ try {
382
+ const pending = [];
383
+ for (const rel of PENDING_SIDECARS) {
384
+ const file = path.join(repoRoot, rel);
385
+ if (!existsSync(file)) continue;
386
+ const ageDays = (now - statSync(file).mtimeMs) / MS_PER_DAY;
387
+ if (ageDays <= SIDECAR_MAX_AGE_DAYS) pending.push(path.basename(rel));
388
+ }
389
+ if (pending.length > 0) markDue('pending-sidecar', pending.join(', '));
390
+ } catch {
391
+ undeterminable.push('pending-sidecar');
392
+ }
393
+
394
+ return {
395
+ due,
396
+ // HR-106: the denominator counts the signals this host actually EVALUATED.
397
+ // A signal a kill-switch turned off was never judged, so reporting it in
398
+ // "3 of 6" would quote a denominator the rule never used.
399
+ total: MAINTENANCE_TOTAL_SIGNALS - skipped.length,
400
+ skipped,
401
+ undeterminable,
402
+ lastHousekeeping: readLastHousekeeping(repoRoot),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Session-start probe entry point.
408
+ *
409
+ * Silent (`null`) when nothing is due and everything was readable, and ALSO
410
+ * when a housekeeping session completed within {@link HOUSEKEEPING_COOLDOWN_DAYS}
411
+ * days — the cooldown is what keeps this instrument rare (HR-101). The
412
+ * computation still runs and is still available via {@link computeMaintenanceDue};
413
+ * only the banner is suppressed.
414
+ *
415
+ * @param {{repoRoot?: string, config?: object, now?: Date|number, platform?: string}} [opts]
416
+ * @returns {Promise<null | {severity: 'warn', message: string}>}
417
+ */
418
+ export async function checkMaintenanceDue(opts = {}) {
419
+ try {
420
+ const computed = await computeMaintenanceDue(opts);
421
+ if (computed.due.length === 0 && computed.undeterminable.length === 0) return null;
422
+
423
+ // Cooldown — no new config key: the housekeeping session's own record IS
424
+ // the "last maintenance run" stamp.
425
+ if (computed.lastHousekeeping !== null) {
426
+ const ageMs = nowMs(opts.now) - Date.parse(computed.lastHousekeeping);
427
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < HOUSEKEEPING_COOLDOWN_DAYS * MS_PER_DAY) {
428
+ return null;
429
+ }
430
+ }
431
+
432
+ // HR-106: every number in the banner is a number the verdict was computed
433
+ // from — no aggregate stands alone without the signal that produced it.
434
+ const detail = computed.due.map((d) => `${d.id}: ${d.detail}`).join(' · ');
435
+ const unknown =
436
+ computed.undeterminable.length > 0
437
+ ? ` · undeterminable: ${computed.undeterminable.join(', ')}`
438
+ : '';
439
+ const message =
440
+ `⚠ maintenance due: ${computed.due.length} of ${computed.total}` +
441
+ (detail ? ` (${detail})` : '') +
442
+ unknown +
443
+ ' — run /session housekeeping.';
444
+
445
+ return { severity: 'warn', message };
446
+ } catch {
447
+ // Defensive catch-all — a session-start banner must never throw.
448
+ return null;
449
+ }
450
+ }
@@ -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