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,558 @@
1
+ /**
2
+ * session-shape.mjs — the ONE place where a session mode becomes an execution
3
+ * shape, plus the record that it did (#1146 pattern, applied to wave shaping).
4
+ *
5
+ * ## Why this module exists at all
6
+ *
7
+ * "How many waves does a `deep` session have, how many agents may each wave
8
+ * dispatch, does Discovery run, what is the per-agent turn budget" was answered
9
+ * in PROSE, in 27 places (measured 2026-09-09), and those places contradicted
10
+ * each other in 8 of the answers. Prose cannot be called, cannot be tested, and
11
+ * cannot record what it decided — so a housekeeping session that ran five waves
12
+ * (6 such sessions measured across consumer repos) looked exactly like one that
13
+ * ran the documented single loop.
14
+ *
15
+ * The shape therefore lives HERE, as a pure function, and records itself as it
16
+ * is resolved. Wave 3 rewires the prose to CITE this module; the prose must not
17
+ * re-derive the numbers, because a second derivation is a ninth contradiction
18
+ * waiting to happen.
19
+ *
20
+ * ## Where the rules come from
21
+ *
22
+ * The ultradeep 7-wave table is the one `skills/session-plan/SKILL.md` § Role-to-Wave Mapping carried until 2026-09-09; since then this module is its SSOT and the prose cites it.
23
+ * The deep raw caps 8/10/8/6/4 are the former `deep | complex` row of the
24
+ * session-plan sizing table (retired from prose 2026-09-09; this module is the SSOT). Everything else is the operator's
25
+ * decision of 2026-09-09, which OVERRIDES the prose it replaces — most notably:
26
+ *
27
+ * - the `waves`-to-role mapping rows for 3 / 4 / 6+ waves are RETIRED. One
28
+ * session type has one natural wave count; a differing `waves` value is
29
+ * recorded in `notes`, never used to re-combine roles.
30
+ * - PRD AC-9 ("`waves < 7` is an error under the ultradeep profile") is
31
+ * DROPPED. Ultradeep ignores `waves` outright and says so in the result
32
+ * (`wavesConfigHonored: false`), which is strictly more useful than an
33
+ * error the operator has to repair before the session may start.
34
+ *
35
+ * ## Profile branching — the revisit trigger this module fires
36
+ *
37
+ * `scripts/lib/state-md.mjs:80-89` states that the `session-profile` vocabulary
38
+ * is deliberately open "unlike `session_type`", with an explicit revisit
39
+ * trigger: *"the first consumer that BRANCHES on a specific profile value — at
40
+ * that point the set becomes load-bearing and belongs in a shared constant."*
41
+ * `resolveSessionShape` is that first consumer, and this is that trigger firing.
42
+ * It validates against `VALID_SESSION_PROFILES` (already a closed set in
43
+ * `session-schema/constants.mjs`) and THROWS on an unknown profile rather than
44
+ * degrading, because an unrecognised profile here would silently produce the
45
+ * plain-deep 5-wave shape for a session the operator asked to run as 7.
46
+ */
47
+
48
+ import { VALID_SESSION_TYPES, VALID_SESSION_PROFILES } from './session-schema/constants.mjs';
49
+ // The isolation/enforcement decision is NOT re-derived here. `wave-sizing.mjs`
50
+ // has owned it since #194 and imports only `session-schema/constants.mjs`
51
+ // (a declared leaf), so this stays a two-module chain with no cycle — which
52
+ // matters because this file is reachable from the SessionStart hook.
53
+ import { resolveIsolation, resolveEnforcement } from './wave-sizing.mjs';
54
+
55
+ /**
56
+ * The one event name this module emits. A plain string LITERAL on purpose: the
57
+ * events-schema census (`tests/lib/events-schema.test.mjs`) greps the source for
58
+ * literals and cannot see a composed or imported name.
59
+ */
60
+ export const SESSION_SHAPE_EVENT = 'orchestrator.session.shape_resolved';
61
+
62
+ /**
63
+ * Shape-contract version. Bump when the returned object's SHAPE changes in a
64
+ * way a consumer could break on; it travels in the event payload so a ledger
65
+ * reader can tell which contract produced a record.
66
+ */
67
+ export const SESSION_SHAPE_VERSION = 1;
68
+
69
+ /** Per-type default turn budget, applied when `max-turns` is `'auto'`. */
70
+ const MAX_TURNS_DEFAULT = Object.freeze({ housekeeping: 8, feature: 15, deep: 25 });
71
+
72
+ /**
73
+ * The maintenance loop a housekeeping session IS. Recorded in `notes` so the
74
+ * single-wave shape carries its own justification: this is not "a deep session
75
+ * with four waves missing", it is a different kind of work.
76
+ *
77
+ * The array is in EXECUTION order, matching the numbered table in
78
+ * `skills/wave-executor/SKILL.md` § "Housekeeping Sessions — the Maintenance
79
+ * Loop". Order is load-bearing, not presentational: `drift-check` runs FIRST
80
+ * because `evolve`/`reconcile`/`memory-cleanup` mutate the learnings store the
81
+ * drift check reads, so a drift check running after them measures the tree the
82
+ * loop just rewrote rather than the one the session inherited.
83
+ */
84
+ const HOUSEKEEPING_LOOP = Object.freeze([
85
+ 'drift-check',
86
+ 'sweep',
87
+ 'evolve',
88
+ 'reconcile',
89
+ 'dialectic',
90
+ 'memory-cleanup',
91
+ ]);
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Agent-cap resolution
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Resolve the `agents-per-wave` Session Config value for one session type.
99
+ *
100
+ * The value is `number | {default: number, [type]: number}` — the second form
101
+ * is what `_coerceInteger` in `scripts/lib/config/coercers.mjs` produces from
102
+ * the documented override syntax `6 (deep: 18)`.
103
+ *
104
+ * THIS IS THE ONLY resolver of that shape (consolidated 2026-09-09). Its two
105
+ * former private copies — `resolveApwCap` in `scripts/lib/wave-resource-gate.mjs`
106
+ * and `resolveAgentsPerWaveCap` in `scripts/lib/resource-probe/evaluate.mjs` —
107
+ * now import this function and call it with `MODE_BLIND_SESSION_TYPE`
108
+ * (`undefined`), which falls back to `cap.default`: the SAFE direction for a
109
+ * resource ceiling (never exceed the base cap). Wave shaping passes the real
110
+ * session type, where `deep: 18` is exactly the number the operator configured.
111
+ *
112
+ * @param {number|{default?: number, [k: string]: number}|unknown} cap
113
+ * @param {string} sessionType
114
+ * @returns {number|null} the resolved cap, or null when nothing usable was given
115
+ */
116
+ export function resolveAgentCap(cap, sessionType) {
117
+ if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
118
+ if (cap === null || typeof cap !== 'object' || Array.isArray(cap)) return null;
119
+
120
+ const record = /** @type {Record<string, unknown>} */ (cap);
121
+ const specific = record[sessionType];
122
+ if (typeof specific === 'number' && Number.isFinite(specific)) return specific;
123
+ const fallback = record.default;
124
+ return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : null;
125
+ }
126
+
127
+ /**
128
+ * Ultradeep's cap lookup order: `ultradeep` → `deep` → `default`. An ultradeep
129
+ * session IS a deep session (`session_type: 'deep'` + a profile), so a repo that
130
+ * configured `6 (deep: 18)` and never heard of the profile must still get 18.
131
+ *
132
+ * @param {unknown} cap
133
+ * @returns {number|null}
134
+ */
135
+ function resolveUltradeepCap(cap) {
136
+ if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
137
+ const specific = /** @type {Record<string, unknown>} */ (cap).ultradeep;
138
+ if (typeof specific === 'number' && Number.isFinite(specific)) return specific;
139
+ }
140
+ return resolveAgentCap(cap, 'deep');
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Wave tables
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Deep, no profile. Raw caps are the `deep | complex` row of the sizing table
149
+ * (formerly `skills/session-plan/SKILL.md` § Agent Count by Tier, now owned here). ONE tier on purpose: the three-tier
150
+ * simple/moderate/complex split was never resolvable from any value a caller
151
+ * actually has, so it produced a range the coordinator picked from by feel —
152
+ * which is where several of the 8 measured contradictions came from.
153
+ */
154
+ const DEEP_WAVES = Object.freeze([
155
+ { role: 'Discovery', raw: 8, writes: false, verification: 'none', allowedPaths: [] },
156
+ { role: 'Impl-Core', raw: 10, writes: true, verification: 'incremental' },
157
+ { role: 'Impl-Polish', raw: 8, writes: true, verification: 'incremental' },
158
+ { role: 'Quality', raw: 6, writes: true, verification: 'full', qualityEarned: true },
159
+ { role: 'Finalization', raw: 4, writes: true, verification: 'git-status' },
160
+ ]);
161
+
162
+ /** Feature. No Discovery — a feature session starts from an agreed scope. */
163
+ const FEATURE_WAVES = Object.freeze([
164
+ { role: 'Impl-Core', raw: 4, writes: true, verification: 'incremental' },
165
+ {
166
+ role: 'Impl-Polish+Quality',
167
+ raw: 4,
168
+ writes: true,
169
+ verification: 'full',
170
+ qualityEarned: true,
171
+ },
172
+ { role: 'Finalization', raw: 2, writes: true, verification: 'git-status' },
173
+ ]);
174
+
175
+ /** Ultradeep — EXACTLY 7 waves (SSOT here; session-plan § Role-to-Wave Mapping cites this table). */
176
+ const ULTRADEEP_WAVES = Object.freeze([
177
+ {
178
+ role: 'Research+Code-Discovery',
179
+ raw: 18,
180
+ writes: false,
181
+ verification: 'none',
182
+ allowedPaths: [],
183
+ maxTurns: 40,
184
+ },
185
+ {
186
+ role: 'Synthesis-Gate',
187
+ raw: 0,
188
+ writes: false,
189
+ verification: 'none',
190
+ coordinatorDirect: true,
191
+ maxTurns: null,
192
+ blockingAsk: true,
193
+ artifact: 'docs/audits/<YYYY-MM-DD>-<slug>.md',
194
+ },
195
+ { role: 'Impl-Core', raw: 8, writes: true, verification: 'incremental' },
196
+ { role: 'Impl-Polish', raw: 8, writes: true, verification: 'incremental' },
197
+ { role: 'Review-Panel', raw: 3, writes: false, verification: 'none', allowedPaths: [] },
198
+ { role: 'Quality', raw: 6, writes: true, verification: 'full', qualityEarned: true },
199
+ { role: 'Release/Finalization', raw: 4, writes: true, verification: 'git-status', maxTurns: 15 },
200
+ ]);
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // The resolution
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Build one wave record from a table row.
208
+ *
209
+ * @param {object} row
210
+ * @param {number} n — 1-based wave number AFTER any renumbering
211
+ * @param {number|null} capLimit — `agents-per-wave` ceiling, or null for none
212
+ * @param {number} resolvedMaxTurns — the session-level per-agent turn budget
213
+ * @param {object} isoOpts
214
+ * @param {string} isoOpts.sessionType — the SHAPE type (never `'unknown'`)
215
+ * @param {string} isoOpts.configIsolation — Session Config `isolation`
216
+ * @param {string} isoOpts.configEnforcement — Session Config `enforcement`
217
+ * @returns {object}
218
+ */
219
+ function buildWave(row, n, capLimit, resolvedMaxTurns, isoOpts) {
220
+ const coordinatorDirect = row.coordinatorDirect === true;
221
+ const agentCapRaw = row.raw;
222
+ const agentCap = coordinatorDirect
223
+ ? 0
224
+ : capLimit === null
225
+ ? agentCapRaw
226
+ : Math.min(agentCapRaw, capLimit);
227
+
228
+ // A per-role override in the table wins over the session-level budget, in
229
+ // BOTH directions: `maxTurns: null` on the Synthesis-Gate is a measured "no
230
+ // agent runs here", not a missing value to be filled from the default.
231
+ const maxTurns = Object.hasOwn(row, 'maxTurns') ? row.maxTurns : resolvedMaxTurns;
232
+
233
+ // Isolation is PER WAVE, because the input that decides it — the agent count —
234
+ // is per wave. A single session-level default was the bug: it read `none` for
235
+ // a ten-agent deep Impl-Core wave, i.e. ten writing agents in ONE working
236
+ // copy, while `resolveIsolation` (the real resolver, #194) says `worktree` for
237
+ // every wave of ≥5 agents.
238
+ //
239
+ // Three cases resolve `none` WITHOUT asking `resolveIsolation`, because they
240
+ // are not dispatches at all: a coordinator-direct wave, a read-only wave, and
241
+ // a wave whose cap resolved below one agent (`resolveIsolation` rejects an
242
+ // agentCount < 1 outright, and a worktree for nobody isolates nothing).
243
+ const isolation =
244
+ coordinatorDirect || row.writes !== true || agentCap < 1
245
+ ? 'none'
246
+ : resolveIsolation({
247
+ agentCount: agentCap,
248
+ sessionType: isoOpts.sessionType,
249
+ collisionRisk: 'low',
250
+ configIsolation: isoOpts.configIsolation,
251
+ });
252
+ const enforcement = resolveEnforcement({
253
+ isolation,
254
+ configEnforcement: isoOpts.configEnforcement,
255
+ });
256
+
257
+ return {
258
+ n,
259
+ role: row.role,
260
+ coordinatorDirect,
261
+ agentCap,
262
+ agentCapRaw,
263
+ writes: row.writes,
264
+ maxTurns,
265
+ isolation,
266
+ enforcement,
267
+ verification: row.verification,
268
+ ...(row.qualityEarned === true ? { qualityEarned: true } : {}),
269
+ ...(row.allowedPaths === undefined ? {} : { allowedPaths: [...row.allowedPaths] }),
270
+ ...(row.blockingAsk === true ? { blockingAsk: true } : {}),
271
+ ...(row.artifact === undefined ? {} : { artifact: row.artifact }),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Resolve a session mode into its execution shape. PURE — no I/O, no clock, no
277
+ * ambient env. `resolveAndRecordSessionShape` is the wrapper that records it.
278
+ *
279
+ * @param {object} [opts]
280
+ * @param {string} [opts.sessionType] — one of `VALID_SESSION_TYPES`. `'unknown'`
281
+ * is the ABSENCE of a measurement (see the constant's docstring) and is shaped
282
+ * as `deep`: the widest shape is the safe reading of "nobody said".
283
+ * @param {string|null} [opts.profile] — one of `VALID_SESSION_PROFILES`, or null.
284
+ * @param {number} [opts.waves] — the Session Config `waves` value. Honoured only
285
+ * as a comparison baseline; see the module docstring.
286
+ * @param {number|object} [opts.agentsPerWave] — the Session Config
287
+ * `agents-per-wave` value, in either of its two shapes.
288
+ * @param {number|'auto'} [opts.maxTurns] — the Session Config `max-turns` value.
289
+ * @param {boolean} [opts.knownScope] — deep only: the scope is already known, so
290
+ * the Discovery wave has nothing to discover and is dropped.
291
+ * @param {'auto'|'worktree'|'none'} [opts.configIsolation] — the Session Config
292
+ * `isolation` value; default `'auto'`. Fed straight to `resolveIsolation`.
293
+ * @param {'strict'|'warn'|'off'} [opts.configEnforcement] — the Session Config
294
+ * `enforcement` value; default `'warn'`. Fed straight to `resolveEnforcement`.
295
+ * @returns {object} the shape
296
+ * @throws {TypeError} on an unknown `sessionType`, `profile`, `configIsolation`
297
+ * or `configEnforcement`
298
+ */
299
+ export function resolveSessionShape(opts = {}) {
300
+ const {
301
+ sessionType,
302
+ profile = null,
303
+ waves = 5,
304
+ agentsPerWave = 6,
305
+ maxTurns = 'auto',
306
+ knownScope = false,
307
+ configIsolation = 'auto',
308
+ configEnforcement = 'warn',
309
+ } = opts;
310
+
311
+ if (typeof sessionType !== 'string' || !VALID_SESSION_TYPES.includes(sessionType)) {
312
+ throw new TypeError(
313
+ `session-shape: unknown sessionType ${JSON.stringify(sessionType)} ` +
314
+ `(expected one of ${VALID_SESSION_TYPES.join(', ')})`,
315
+ );
316
+ }
317
+ if (profile !== null && profile !== undefined) {
318
+ if (typeof profile !== 'string' || !VALID_SESSION_PROFILES.includes(profile)) {
319
+ throw new TypeError(
320
+ `session-shape: unknown profile ${JSON.stringify(profile)} ` +
321
+ `(expected one of ${VALID_SESSION_PROFILES.join(', ')}, or null)`,
322
+ );
323
+ }
324
+ }
325
+
326
+ const effectiveProfile = profile ?? null;
327
+ // `unknown` is not a fourth mode — it is "not measured". Shape it as deep.
328
+ const shapeType = sessionType === 'unknown' ? 'deep' : sessionType;
329
+
330
+ // Up-front input validation (#1290 item 1). Before this, `configIsolation`
331
+ // and `configEnforcement` were validated only where `buildWave` happened to
332
+ // reach `resolveIsolation` — so a shape whose waves ALL short-circuit to
333
+ // `none` never judged them at all, and `configIsolation: 'worktre'` produced
334
+ // a normal-looking housekeeping shape. Validation happens by CALLING the two
335
+ // owning resolvers rather than by re-listing their value sets: `wave-sizing.mjs`
336
+ // has owned both since #194, does not export the arrays, and a copy here would
337
+ // be exactly the second definition this module exists to abolish.
338
+ try {
339
+ resolveIsolation({ agentCount: 1, sessionType: shapeType, configIsolation });
340
+ resolveEnforcement({ isolation: 'none', configEnforcement });
341
+ } catch (err) {
342
+ // Re-prefixed so the CLI's `session-shape: ` strip + EXIT_INPUT mapping
343
+ // treats it like every other input error of this module.
344
+ throw new TypeError(`session-shape: ${err instanceof Error ? err.message : String(err)}`, {
345
+ cause: err,
346
+ });
347
+ }
348
+
349
+ const maxTurnsDefault = MAX_TURNS_DEFAULT[shapeType];
350
+ const resolvedMaxTurns =
351
+ typeof maxTurns === 'number' && Number.isFinite(maxTurns) && maxTurns > 0
352
+ ? maxTurns
353
+ : maxTurnsDefault;
354
+
355
+ /** @type {string[]} */ const notes = [];
356
+ const isUltradeep = effectiveProfile === 'ultradeep';
357
+
358
+ let table;
359
+ let discovery;
360
+ let coordinatorDirect = false;
361
+ let wavesConfigHonored = true;
362
+ let wavesConfigIgnoredValue;
363
+
364
+ if (shapeType === 'housekeeping') {
365
+ table = [
366
+ {
367
+ role: 'Housekeeping',
368
+ raw: 0,
369
+ writes: true,
370
+ verification: 'full',
371
+ coordinatorDirect: true,
372
+ maxTurns: null,
373
+ },
374
+ ];
375
+ discovery = false;
376
+ coordinatorDirect = true;
377
+ notes.push(
378
+ `housekeeping is the maintenance loop, in execution order: ${HOUSEKEEPING_LOOP.join(', ')}`,
379
+ );
380
+ } else if (shapeType === 'feature') {
381
+ table = FEATURE_WAVES;
382
+ discovery = false;
383
+ } else if (isUltradeep) {
384
+ table = ULTRADEEP_WAVES;
385
+ discovery = true;
386
+ wavesConfigHonored = false;
387
+ wavesConfigIgnoredValue = waves;
388
+ notes.push(
389
+ `waves: ${waves} configured; the ultradeep profile is a fixed 7-wave shape ` +
390
+ '(scripts/lib/session-shape.mjs, ULTRADEEP table) and ignores the value. PRD AC-9 ' +
391
+ '("waves < 7 is an error") was dropped 2026-09-09.',
392
+ );
393
+ } else {
394
+ table = knownScope ? DEEP_WAVES.slice(1) : DEEP_WAVES;
395
+ discovery = !knownScope;
396
+ if (knownScope) {
397
+ notes.push('knownScope: true — the Discovery wave is dropped and the rest renumbered.');
398
+ }
399
+ }
400
+
401
+ const capLimit = isUltradeep
402
+ ? resolveUltradeepCap(agentsPerWave)
403
+ : resolveAgentCap(agentsPerWave, shapeType);
404
+
405
+ // A cap of 0 is not a small plan, it is a plan that dispatches NOBODY — and
406
+ // without this note the resulting shape (every `agentCap: 0`) is textually
407
+ // indistinguishable from a normal one, so the coordinator reads it as agreed.
408
+ if (capLimit !== null && capLimit < 1) {
409
+ notes.push(
410
+ `agents-per-wave resolves to ${capLimit} — every wave would dispatch nobody; ` +
411
+ 'check Session Config',
412
+ );
413
+ }
414
+
415
+ const waveRecords = table.map((row, i) =>
416
+ buildWave(row, i + 1, capLimit, resolvedMaxTurns, {
417
+ sessionType: shapeType,
418
+ configIsolation,
419
+ configEnforcement,
420
+ }),
421
+ );
422
+ const totalWaves = waveRecords.length;
423
+
424
+ // A `waves` value that disagrees with the shape's natural count is RECORDED,
425
+ // never obeyed — the role-combination rows it used to drive (3 / 4 / 6+) were
426
+ // retired 2026-09-09 because they were a second, contradicting definition of
427
+ // what each role does.
428
+ if (wavesConfigHonored && Number.isFinite(waves) && waves !== totalWaves) {
429
+ notes.push(
430
+ `waves: ${waves} configured; ${shapeType} shape is ${totalWaves} ` +
431
+ '(role-combination splitting retired 2026-09-09)',
432
+ );
433
+ }
434
+
435
+ return {
436
+ version: SESSION_SHAPE_VERSION,
437
+ sessionType,
438
+ profile: effectiveProfile,
439
+ totalWaves,
440
+ wavesConfigHonored,
441
+ ...(wavesConfigIgnoredValue === undefined ? {} : { wavesConfigIgnoredValue }),
442
+ discovery,
443
+ coordinatorDirect,
444
+ maxTurnsDefault,
445
+ waves: waveRecords,
446
+ notes,
447
+ };
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // The record
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Read a Session Config value, tolerating a non-object config.
456
+ *
457
+ * @param {unknown} config
458
+ * @param {string} key
459
+ * @returns {unknown}
460
+ */
461
+ function readConfigKey(config, key) {
462
+ if (config === null || typeof config !== 'object') return undefined;
463
+ return /** @type {Record<string, unknown>} */ (config)[key];
464
+ }
465
+
466
+ /**
467
+ * Append the shape record. Best-effort in the strict sense — it can never
468
+ * change, delay past its own await, or throw into the shape.
469
+ *
470
+ * `events.mjs` is imported LAZILY for the same measured reason
471
+ * `scripts/lib/express-path.mjs` gives: a static import drags in `platform.mjs`,
472
+ * which runs filesystem walk-ups at MODULE LOAD, onto every consumer that only
473
+ * wants the pure resolver. The lazy form also puts the telemetry module's own
474
+ * load inside the try/catch.
475
+ *
476
+ * @param {string|undefined} repoRoot
477
+ * @param {object} shape
478
+ * @param {number|null|undefined} taskCount
479
+ * @returns {Promise<void>}
480
+ */
481
+ async function _emitShapeResolved(repoRoot, shape, taskCount) {
482
+ // Refuse the SO_PROJECT_DIR fallback rather than guess a destination: without
483
+ // an explicit repoRoot, `emitEvent` writes to whatever tree the ambient env
484
+ // resolves to. A skipped record is recoverable; a record in the wrong ledger
485
+ // is not (#941).
486
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
487
+ process.stderr.write(
488
+ `session-shape: skipped ${SESSION_SHAPE_EVENT} — no repoRoot given; ` +
489
+ 'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
490
+ );
491
+ return;
492
+ }
493
+
494
+ const payload = {
495
+ session_type: shape.sessionType,
496
+ // Omitted, never written as null/'' — an absent key is the only honest
497
+ // encoding of "this session has no profile".
498
+ ...(shape.profile === null ? {} : { session_profile: shape.profile }),
499
+ total_waves: shape.totalWaves,
500
+ waves_config_honored: shape.wavesConfigHonored,
501
+ discovery: shape.discovery,
502
+ agent_caps: shape.waves.map((w) => w.agentCap),
503
+ coordinator_direct_waves: shape.waves.filter((w) => w.coordinatorDirect).map((w) => w.n),
504
+ shape_version: shape.version,
505
+ ...(Number.isInteger(taskCount) && taskCount >= 0 ? { task_count: taskCount } : {}),
506
+ };
507
+
508
+ try {
509
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
510
+ await emitEvent(SESSION_SHAPE_EVENT, { ...payload, ...sessionAttribution(repoRoot) }, { repoRoot });
511
+ } catch {
512
+ // Best-effort telemetry — the shape is authoritative whether or not the
513
+ // ledger accepted the record.
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Resolve the shape from a `parseSessionConfig()` result AND record it.
519
+ *
520
+ * @param {object} [opts]
521
+ * @param {string} [opts.repoRoot] — repo whose `.orchestrator/metrics/events.jsonl`
522
+ * receives the record. REQUIRED for the record; the shape is returned regardless.
523
+ * @param {object} [opts.config] — a `parseSessionConfig()` result. Read for
524
+ * `waves`, `agents-per-wave`, `max-turns`, `isolation` and `enforcement` only.
525
+ * @param {string} [opts.sessionType]
526
+ * @param {string|null} [opts.profile]
527
+ * @param {boolean} [opts.knownScope]
528
+ * @param {number|null} [opts.taskCount]
529
+ * @param {boolean} [opts.emit] — default true. `false` resolves without touching
530
+ * the ledger; the CLI's `--no-event` planning dry-run is the only caller that
531
+ * sets it, and it exists so a dry-run cannot leave a record claiming a session
532
+ * was shaped.
533
+ * @returns {Promise<object>} the shape
534
+ */
535
+ export async function resolveAndRecordSessionShape(opts = {}) {
536
+ const { repoRoot, config, sessionType, profile, knownScope, taskCount, emit = true } = opts;
537
+
538
+ const wavesValue = readConfigKey(config, 'waves');
539
+ const agentsValue = readConfigKey(config, 'agents-per-wave');
540
+ const maxTurnsValue = readConfigKey(config, 'max-turns');
541
+ const isolationValue = readConfigKey(config, 'isolation');
542
+ const enforcementValue = readConfigKey(config, 'enforcement');
543
+
544
+ const shape = resolveSessionShape({
545
+ sessionType,
546
+ profile: profile ?? null,
547
+ ...(wavesValue === undefined ? {} : { waves: wavesValue }),
548
+ ...(agentsValue === undefined ? {} : { agentsPerWave: agentsValue }),
549
+ ...(maxTurnsValue === undefined ? {} : { maxTurns: maxTurnsValue }),
550
+ ...(isolationValue === undefined ? {} : { configIsolation: isolationValue }),
551
+ ...(enforcementValue === undefined ? {} : { configEnforcement: enforcementValue }),
552
+ knownScope: knownScope === true,
553
+ });
554
+
555
+ if (emit !== false) await _emitShapeResolved(repoRoot, shape, taskCount);
556
+
557
+ return shape;
558
+ }
@@ -260,9 +260,16 @@ export const PROBES = [
260
260
  args: ({ repoRoot }) => ({ repoRoot }),
261
261
  },
262
262
  {
263
- id: 'reconcile-nudge',
264
- spec: local('./reconcile-nudge-banner.mjs'),
265
- fn: 'checkReconcileNudge',
263
+ // Replaces the former `reconcile-nudge` entry AND the two session-end
264
+ // nudges (3.6.5 auto-dream, 3.6.7 auto-dialectic): one reading of the whole
265
+ // maintenance loop, at the one moment the operator can act on it. The
266
+ // reconcile signal is not lost — `maintenance-due-banner.mjs` calls
267
+ // `computeReconcileNudge` wholesale as its S3, so `reconcile-nudge-banner.mjs`
268
+ // remains a live dependency, just no longer its own registry entry (a
269
+ // second entry would double-report the same finding).
270
+ id: 'maintenance-due',
271
+ spec: local('./maintenance-due-banner.mjs'),
272
+ fn: 'checkMaintenanceDue',
266
273
  network: false,
267
274
  args: ({ repoRoot, config }) => ({ repoRoot, config }),
268
275
  },