session-orchestrator 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +8 -8
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
|
@@ -0,0 +1,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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
},
|