@hegemonart/get-design-done 1.27.1 → 1.27.5

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * scripts/lib/bandit-router/integration.cjs — Plan 27.5-01
3
+ *
4
+ * Production-integration shim for the Phase 23.5 bandit posterior +
5
+ * Phase 27-07 delegate dimension. Hides the `pull` vs `pullWithDelegate`
6
+ * + `update` vs `updateWithDelegate` choice from callers.
7
+ *
8
+ * Two functions:
9
+ * consultBandit({agent, bin, delegate, agentFrontmatter, adaptiveMode, baseDir?, posteriorPath?})
10
+ * → {tier, decision_log}
11
+ * recordOutcome({agent, bin, delegate, tier, status, costUsd, adaptiveMode, baseDir?, posteriorPath?})
12
+ * → void (best-effort write per D-04)
13
+ *
14
+ * Routing rules (D-05 + D-07):
15
+ * 1. agentFrontmatter.tier_override is set → bypass bandit, return tier_override
16
+ * 2. adaptiveMode !== 'full' → bandit silent, return frontmatter.default_tier
17
+ * (covers 'static' and 'hedge' per D-07)
18
+ * 3. adaptiveMode === 'full' && delegate is 'none' / undefined
19
+ * → call pull()
20
+ * 4. adaptiveMode === 'full' && delegate is a peer name
21
+ * → call pullWithDelegate({delegates:[delegate]})
22
+ *
23
+ * recordOutcome is symmetric on the adaptive_mode gate:
24
+ * - non-'full' → no-op
25
+ * - 'full' && delegate 'none'/undefined → update()
26
+ * - 'full' && delegate is a peer → updateWithDelegate()
27
+ *
28
+ * Reward function = Phase 23.5's computeReward unchanged (D-08).
29
+ *
30
+ * Posterior writes are best-effort — all throws are swallowed. The
31
+ * shim's job is to plumb the call; telemetry resilience is downstream.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const banditRouter = require('../bandit-router.cjs');
37
+ const adaptiveModeLib = require('../adaptive-mode.cjs');
38
+
39
+ const DELEGATE_NONE = banditRouter.DELEGATE_NONE; // 'none'
40
+ const VALID_DELEGATES = banditRouter.DEFAULT_DELEGATES; // ['none','gemini','codex','cursor','copilot','qwen']
41
+
42
+ /**
43
+ * Validate that `delegate` is either undefined, DELEGATE_NONE, or a
44
+ * member of VALID_DELEGATES. Returns the canonical delegate string
45
+ * (undefined → 'none').
46
+ *
47
+ * @param {string|undefined} delegate
48
+ * @param {string} fnName — for error message context
49
+ * @returns {string}
50
+ */
51
+ function resolveDelegate(delegate, fnName) {
52
+ if (delegate === undefined || delegate === null) return DELEGATE_NONE;
53
+ if (typeof delegate !== 'string') {
54
+ throw new TypeError(
55
+ `integration.${fnName}: delegate must be a string when provided, got ${typeof delegate}`,
56
+ );
57
+ }
58
+ if (!VALID_DELEGATES.includes(delegate)) {
59
+ throw new RangeError(
60
+ `integration.${fnName}: unknown delegate '${delegate}'; expected one of ${VALID_DELEGATES.join(',')}`,
61
+ );
62
+ }
63
+ return delegate;
64
+ }
65
+
66
+ /**
67
+ * Resolve the adaptive_mode for a call. If the caller passed it
68
+ * explicitly we use that; otherwise we read it from disk via
69
+ * adaptive-mode.getMode (D-07: single gating surface).
70
+ *
71
+ * @param {string|undefined} adaptiveMode
72
+ * @param {{baseDir?: string}} opts
73
+ * @returns {'static'|'hedge'|'full'}
74
+ */
75
+ function resolveAdaptiveMode(adaptiveMode, opts) {
76
+ if (typeof adaptiveMode === 'string' && adaptiveMode.length > 0) {
77
+ return /** @type {'static'|'hedge'|'full'} */ (adaptiveMode);
78
+ }
79
+ return adaptiveModeLib.getMode({ baseDir: opts && opts.baseDir, quiet: true });
80
+ }
81
+
82
+ /**
83
+ * consultBandit — single canonical lookup that returns a tier + a
84
+ * decision_log explaining how the tier was chosen. Five paths:
85
+ *
86
+ * Path 1 — static mode → frontmatter.default_tier (or 'sonnet' fallback)
87
+ * Path 2 — tier_override set on frontmatter → bypass bandit
88
+ * Path 3 — full mode + delegate='none' (or undefined) → pull()
89
+ * Path 4 — full mode + delegate=<peer> → pullWithDelegate()
90
+ * Path 5 — hedge mode → frontmatter.default_tier (bandit silent)
91
+ *
92
+ * Path 2 takes precedence over Path 1 / 3 / 4 / 5 (tier_override is the
93
+ * explicit operator override per D-05).
94
+ *
95
+ * @param {{
96
+ * agent: string,
97
+ * bin: string,
98
+ * delegate?: string,
99
+ * agentFrontmatter?: {tier_override?: string, default_tier?: string},
100
+ * adaptiveMode?: 'static'|'hedge'|'full',
101
+ * baseDir?: string,
102
+ * posteriorPath?: string,
103
+ * }} input
104
+ * @returns {{
105
+ * tier: string,
106
+ * decision_log: {
107
+ * source: 'frontmatter'|'tier_override_bypass'|'bandit_pull'|'bandit_pull_with_delegate',
108
+ * samples?: object,
109
+ * delegate?: string,
110
+ * adaptive_mode: 'static'|'hedge'|'full',
111
+ * reason?: string,
112
+ * }
113
+ * }}
114
+ */
115
+ function consultBandit(input) {
116
+ if (!input || typeof input !== 'object') {
117
+ throw new TypeError('integration.consultBandit: input object required');
118
+ }
119
+ if (typeof input.agent !== 'string' || input.agent.length === 0) {
120
+ throw new TypeError('integration.consultBandit: agent (string) required');
121
+ }
122
+ if (typeof input.bin !== 'string' || input.bin.length === 0) {
123
+ throw new TypeError('integration.consultBandit: bin (string) required');
124
+ }
125
+
126
+ const agentFrontmatter = input.agentFrontmatter && typeof input.agentFrontmatter === 'object'
127
+ ? input.agentFrontmatter
128
+ : {};
129
+ const adaptiveMode = resolveAdaptiveMode(input.adaptiveMode, input);
130
+ const delegate = resolveDelegate(input.delegate, 'consultBandit');
131
+
132
+ // Step 1 — tier_override bypass (D-05). Highest priority; beats both
133
+ // bandit consultation and static/hedge frontmatter.default_tier.
134
+ if (typeof agentFrontmatter.tier_override === 'string' && agentFrontmatter.tier_override.length > 0) {
135
+ return {
136
+ tier: agentFrontmatter.tier_override,
137
+ decision_log: {
138
+ source: 'tier_override_bypass',
139
+ adaptive_mode: adaptiveMode,
140
+ reason: 'frontmatter_tier_override_set',
141
+ },
142
+ };
143
+ }
144
+
145
+ // Step 2 — non-full short-circuit (D-07). Static and hedge are both
146
+ // "bandit silent"; frontmatter.default_tier (or 'sonnet' fallback)
147
+ // is authoritative. No posterior read or write.
148
+ if (adaptiveMode !== 'full') {
149
+ const fallbackTier = (typeof agentFrontmatter.default_tier === 'string' && agentFrontmatter.default_tier.length > 0)
150
+ ? agentFrontmatter.default_tier
151
+ : 'sonnet';
152
+ return {
153
+ tier: fallbackTier,
154
+ decision_log: {
155
+ source: 'frontmatter',
156
+ adaptive_mode: adaptiveMode,
157
+ reason: adaptiveMode === 'hedge' ? 'hedge_mode_skips_bandit' : 'static_mode_authoritative',
158
+ },
159
+ };
160
+ }
161
+
162
+ // Step 3/4 — full mode → consult the bandit. Choice of pull vs
163
+ // pullWithDelegate is driven by `delegate`:
164
+ // delegate === 'none' (or undefined → 'none') → pull()
165
+ // delegate ∈ {gemini,codex,cursor,copilot,qwen} → pullWithDelegate
166
+ if (delegate === DELEGATE_NONE) {
167
+ const result = banditRouter.pull({
168
+ agent: input.agent,
169
+ bin: input.bin,
170
+ baseDir: input.baseDir,
171
+ posteriorPath: input.posteriorPath,
172
+ });
173
+ return {
174
+ tier: result.tier,
175
+ decision_log: {
176
+ source: 'bandit_pull',
177
+ samples: result.samples,
178
+ delegate: DELEGATE_NONE,
179
+ adaptive_mode: 'full',
180
+ },
181
+ };
182
+ }
183
+
184
+ // Path 4 — peer delegate. Constrain the delegate axis to the single
185
+ // requested peer so the bandit samples the (tier × delegate) joint
186
+ // restricted to {delegate}. Same posterior file, same arm shape; the
187
+ // arm's `delegate` field is set so the slice is distinct from local.
188
+ const result = banditRouter.pullWithDelegate({
189
+ agent: input.agent,
190
+ bin: input.bin,
191
+ delegates: [delegate],
192
+ baseDir: input.baseDir,
193
+ posteriorPath: input.posteriorPath,
194
+ });
195
+ return {
196
+ tier: result.tier,
197
+ decision_log: {
198
+ source: 'bandit_pull_with_delegate',
199
+ samples: result.samples,
200
+ delegate: result.delegate,
201
+ adaptive_mode: 'full',
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * recordOutcome — post-spawn telemetry update. Computes a reward via
208
+ * computeReward (Phase 23.5 D-08, unchanged) and writes the posterior
209
+ * arm. Best-effort: all errors swallowed so telemetry can never break
210
+ * a session (D-04).
211
+ *
212
+ * No-op when adaptive_mode is not 'full' (D-07).
213
+ *
214
+ * @param {{
215
+ * agent: string,
216
+ * bin: string,
217
+ * delegate?: string,
218
+ * tier: string,
219
+ * status: string,
220
+ * costUsd?: number,
221
+ * adaptiveMode?: 'static'|'hedge'|'full',
222
+ * baseDir?: string,
223
+ * posteriorPath?: string,
224
+ * }} input
225
+ * @returns {void}
226
+ */
227
+ function recordOutcome(input) {
228
+ if (!input || typeof input !== 'object') {
229
+ throw new TypeError('integration.recordOutcome: input object required');
230
+ }
231
+ if (typeof input.agent !== 'string' || input.agent.length === 0) {
232
+ throw new TypeError('integration.recordOutcome: agent (string) required');
233
+ }
234
+ if (typeof input.bin !== 'string' || input.bin.length === 0) {
235
+ throw new TypeError('integration.recordOutcome: bin (string) required');
236
+ }
237
+ if (typeof input.tier !== 'string' || input.tier.length === 0) {
238
+ throw new TypeError('integration.recordOutcome: tier (string) required');
239
+ }
240
+ if (typeof input.status !== 'string') {
241
+ throw new TypeError('integration.recordOutcome: status (string) required');
242
+ }
243
+
244
+ const adaptiveMode = resolveAdaptiveMode(input.adaptiveMode, input);
245
+
246
+ // D-07 + D-04: posterior is silent in static/hedge. No-op early.
247
+ if (adaptiveMode !== 'full') {
248
+ return undefined;
249
+ }
250
+
251
+ const delegate = resolveDelegate(input.delegate, 'recordOutcome');
252
+
253
+ // D-08: reward function unchanged. wall_time_ms always 0 per
254
+ // Phase 23.5 / 27.5 — the wall-time tiebreaker is not used at the
255
+ // recordOutcome boundary; correctness + cost are the only signals.
256
+ const reward = banditRouter.computeReward({
257
+ solidify_pass: input.status === 'completed',
258
+ cost_usd: typeof input.costUsd === 'number' ? input.costUsd : 0,
259
+ wall_time_ms: 0,
260
+ });
261
+
262
+ // D-04: best-effort write. Swallow ALL exceptions so a broken
263
+ // posterior file never breaks a session.
264
+ try {
265
+ if (delegate === DELEGATE_NONE) {
266
+ banditRouter.update({
267
+ agent: input.agent,
268
+ bin: input.bin,
269
+ tier: input.tier,
270
+ reward,
271
+ baseDir: input.baseDir,
272
+ posteriorPath: input.posteriorPath,
273
+ });
274
+ } else {
275
+ banditRouter.updateWithDelegate({
276
+ agent: input.agent,
277
+ bin: input.bin,
278
+ tier: input.tier,
279
+ delegate,
280
+ reward,
281
+ baseDir: input.baseDir,
282
+ posteriorPath: input.posteriorPath,
283
+ });
284
+ }
285
+ } catch (err) {
286
+ // Live-tail breadcrumb opt-in via env var. Inner try/catch around
287
+ // the stderr write itself keeps the swallow guarantee even when
288
+ // stderr is closed/unavailable.
289
+ if (process.env.GDD_BANDIT_DEBUG === '1') {
290
+ try {
291
+ process.stderr.write(
292
+ '[bandit-integration] recordOutcome swallowed: ' +
293
+ (err && err.message ? err.message : String(err)) +
294
+ '\n',
295
+ );
296
+ } catch {
297
+ /* swallow */
298
+ }
299
+ }
300
+ }
301
+
302
+ return undefined;
303
+ }
304
+
305
+ module.exports = {
306
+ consultBandit,
307
+ recordOutcome,
308
+ DELEGATE_NONE,
309
+ };
@@ -80,12 +80,130 @@ const rateGuard = _nodeRequire(
80
80
  ingestHeaders: (provider: string, headers: unknown) => Promise<unknown>;
81
81
  };
82
82
 
83
+ // ── Plan 27.5-03 — Bandit posterior feedback loop ────────────────────────────
84
+ //
85
+ // `integration.cjs` is the Phase 27.5-01 production-integration shim for the
86
+ // Phase 23.5 bandit posterior. It exposes `recordOutcome({agent, bin, delegate,
87
+ // tier, status, costUsd, adaptiveMode, baseDir?, posteriorPath?})` which writes
88
+ // the (status + cost) reward back to the posterior arm for the (agent × bin ×
89
+ // tier × delegate) joint. Per CONTEXT D-04, the call fires AFTER every
90
+ // `emit('session.completed', …)` site so the posterior reflects the measured
91
+ // signal — correctness + cost.
92
+ //
93
+ // The shim is no-throw (best-effort write). The session-runner wraps each
94
+ // `recordOutcome` call in its own try/catch as a defensive belt-and-braces
95
+ // guard against future shim changes.
96
+ const banditIntegration = _nodeRequire(
97
+ _resolve(_REPO_ROOT, 'scripts/lib/bandit-router/integration.cjs'),
98
+ ) as {
99
+ recordOutcome: (input: {
100
+ agent: string;
101
+ bin: string;
102
+ delegate?: string;
103
+ tier: string;
104
+ status: string;
105
+ costUsd?: number;
106
+ adaptiveMode?: 'static' | 'hedge' | 'full';
107
+ baseDir?: string;
108
+ posteriorPath?: string;
109
+ }) => void;
110
+ DELEGATE_NONE: string;
111
+ };
112
+
113
+ // ── Plan 27.5-03 — adaptive-mode read once per run ──────────────────────────
114
+ //
115
+ // `adaptive-mode.cjs.getMode({quiet: true})` reads `.design/budget.json` and
116
+ // returns `'static' | 'hedge' | 'full'`. We cache the resolved mode locally
117
+ // on each `run()` invocation so the recordOutcome calls at the 4 terminal
118
+ // emit sites all see the same value (consistent gating per session).
119
+ const adaptiveModeLib = _nodeRequire(
120
+ _resolve(_REPO_ROOT, 'scripts/lib/adaptive-mode.cjs'),
121
+ ) as {
122
+ getMode: (opts?: { baseDir?: string; budgetPath?: string; quiet?: boolean }) => 'static' | 'hedge' | 'full';
123
+ };
124
+
83
125
  /** Rate-guard provider key for the Anthropic Agent SDK. */
84
126
  const RATE_GUARD_PROVIDER = 'anthropic';
85
127
 
86
128
  /** Default retries (first attempt + 1 retry). */
87
129
  const DEFAULT_MAX_RETRIES = 2;
88
130
 
131
+ /**
132
+ * Default bin marker for bandit posterior writes from session-runner.
133
+ *
134
+ * Per CONTEXT D-12, session-runner uses a deterministic placeholder bin
135
+ * (`'medium'`) for now; real complexity-class-based bin selection is
136
+ * deferred to a later plan. This matches the 27.5-02 budget-enforcer
137
+ * convention so the (agent × bin) posterior slices stay consistent
138
+ * across both write paths.
139
+ */
140
+ const SESSION_RUNNER_DEFAULT_BIN = 'medium';
141
+
142
+ /**
143
+ * Infer a tier ('opus' | 'sonnet' | 'haiku') from a model identifier.
144
+ *
145
+ * Used at the 4 terminal-emit sites where the final tier isn't already
146
+ * carried on `opts` — we fall back to inspecting `usage.model` (folded
147
+ * during the run loop from SDK chunks). Unknown / empty model names
148
+ * default to 'sonnet' (matches the DEFAULT_MODEL_RATE choice and is
149
+ * the safest middle tier for posterior arms).
150
+ */
151
+ function tierFromModel(modelName: string | null | undefined): 'opus' | 'sonnet' | 'haiku' {
152
+ if (typeof modelName !== 'string' || modelName.length === 0) return 'sonnet';
153
+ const lower = modelName.toLowerCase();
154
+ if (lower.includes('opus')) return 'opus';
155
+ if (lower.includes('haiku')) return 'haiku';
156
+ return 'sonnet';
157
+ }
158
+
159
+ /**
160
+ * Best-effort bandit posterior write following `emit('session.completed', …)`.
161
+ *
162
+ * Per CONTEXT D-04: posterior updates happen AT the terminal emit site so the
163
+ * recorded reward reflects the same (status + cost) the rest of the system
164
+ * just observed. The shim (`integration.cjs`) is no-throw and short-circuits
165
+ * silently in static/hedge mode; the outer try/catch here is a defensive
166
+ * belt-and-braces guard for any future shim change.
167
+ *
168
+ * Failures NEVER bubble out — the session-runner contract is that `run()`
169
+ * never throws, and that contract MUST hold even when telemetry is broken.
170
+ */
171
+ function _recordBanditOutcome(input: {
172
+ agent: string;
173
+ bin: string;
174
+ delegate: string;
175
+ tier: string;
176
+ status: string;
177
+ costUsd: number;
178
+ adaptiveMode: 'static' | 'hedge' | 'full';
179
+ }): void {
180
+ try {
181
+ banditIntegration.recordOutcome({
182
+ agent: input.agent,
183
+ bin: input.bin,
184
+ delegate: input.delegate,
185
+ tier: input.tier,
186
+ status: input.status,
187
+ costUsd: input.costUsd,
188
+ adaptiveMode: input.adaptiveMode,
189
+ });
190
+ } catch (err) {
191
+ // Defensive: shim is no-throw, but a future change could regress.
192
+ // Telemetry failure must never break a session — swallow.
193
+ if (process.env['GDD_BANDIT_DEBUG'] === '1') {
194
+ try {
195
+ process.stderr.write(
196
+ '[session-runner] _recordBanditOutcome swallowed: ' +
197
+ (err instanceof Error ? err.message : String(err)) +
198
+ '\n',
199
+ );
200
+ } catch {
201
+ /* swallow */
202
+ }
203
+ }
204
+ }
205
+ }
206
+
89
207
  // ── Plan 27-06 — Peer-CLI delegation primitives ─────────────────────────────
90
208
  //
91
209
  // Lazy registry loader: the registry is a .cjs module under scripts/lib/peer-cli
@@ -680,6 +798,22 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
680
798
  const toolCalls: SessionResult['tool_calls'] = [];
681
799
  const usage = { input: 0, output: 0, model: null as string | null };
682
800
  let turns = 0;
801
+
802
+ // -- 3a. Resolve adaptive-mode once for the entire session (Plan 27.5-03). --
803
+ // Cached locally so all four `recordOutcome()` call sites below see the
804
+ // same gating decision (consistent posterior-write semantics across
805
+ // rate-limit, peer, turnCap=0, and terminal-completion paths).
806
+ //
807
+ // Wrapped in try/catch because adaptive-mode.getMode reads
808
+ // `.design/budget.json`; a broken fs.readFile / JSON.parse must not
809
+ // crash the session before it even starts. Fallback = 'static' which
810
+ // short-circuits the recordOutcome shim (no-op).
811
+ let adaptiveMode: 'static' | 'hedge' | 'full' = 'static';
812
+ try {
813
+ adaptiveMode = adaptiveModeLib.getMode({ quiet: true });
814
+ } catch {
815
+ // swallow — fallback to 'static' means no posterior writes
816
+ }
683
817
  let finalText: string | undefined;
684
818
 
685
819
  // -- 4. Emit session.started. -------------------------------------------
@@ -719,6 +853,20 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
719
853
  transcript_path: transcriptPath,
720
854
  sanitizer: { applied: [...result.sanitizer.applied], removedSections: [...result.sanitizer.removedSections] },
721
855
  });
856
+ // Plan 27.5-03 — feedback loop. Posterior records the
857
+ // measured outcome (status + cost) for the (agent × bin × tier × delegate)
858
+ // slice. The rate-limit preflight failure path has no peer dispatch and no
859
+ // usage data (zero cost), so delegate=DELEGATE_NONE and tier falls back to
860
+ // 'sonnet' via tierFromModel(null). Shim no-ops in static/hedge mode.
861
+ _recordBanditOutcome({
862
+ agent: opts.stage,
863
+ bin: SESSION_RUNNER_DEFAULT_BIN,
864
+ delegate: banditIntegration.DELEGATE_NONE,
865
+ tier: tierFromModel(usage.model),
866
+ status: result.status,
867
+ costUsd: result.usage.usd_cost,
868
+ adaptiveMode,
869
+ });
722
870
  transcript.close();
723
871
  return result;
724
872
  }
@@ -765,6 +913,37 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
765
913
  transcript_path: transcriptPath,
766
914
  sanitizer: { applied: [...peerResult.sanitizer.applied], removedSections: [...peerResult.sanitizer.removedSections] },
767
915
  });
916
+ // Plan 27.5-03 — feedback loop, peer path. The
917
+ // delegate dimension is the peer name parsed from opts.delegateTo (e.g.
918
+ // 'gemini-research' → 'gemini'). Per CONTEXT D-04 we use the peer name
919
+ // for the delegate slice of the posterior so peer-success arms get the
920
+ // reward signal separately from local arms. Tier is 'sonnet' by default
921
+ // since the peer adapter doesn't surface a model identifier in v1.27.
922
+ // Re-parse opts.delegateTo here — tryDelegate already validated it but
923
+ // didn't expose the peer name on the returned SessionResult.
924
+ const _peerParsed = parseDelegateTo(opts.delegateTo);
925
+ const _delegate = _peerParsed !== null
926
+ ? _peerParsed.peer
927
+ : banditIntegration.DELEGATE_NONE;
928
+ // Tier resolution priority for the peer path:
929
+ // 1. opts.delegateTier when it's a bare tier name (opus/sonnet/haiku)
930
+ // 2. tierFromModel(opts.delegateTier) when it's a model id string
931
+ // 3. tierFromModel(usage.model) fallback
932
+ // tierFromModel() is safe for any string and returns 'sonnet' on miss,
933
+ // so the second branch covers both bare-tier and model-id inputs.
934
+ const _peerTier: 'opus' | 'sonnet' | 'haiku' =
935
+ typeof opts.delegateTier === 'string' && opts.delegateTier.length > 0
936
+ ? tierFromModel(opts.delegateTier)
937
+ : tierFromModel(usage.model);
938
+ _recordBanditOutcome({
939
+ agent: opts.stage,
940
+ bin: SESSION_RUNNER_DEFAULT_BIN,
941
+ delegate: _delegate,
942
+ tier: _peerTier,
943
+ status: peerResult.status,
944
+ costUsd: peerResult.usage.usd_cost,
945
+ adaptiveMode,
946
+ });
768
947
  transcript.close();
769
948
  if (opts.signal !== undefined) opts.signal.removeEventListener('abort', onExternalAbort);
770
949
  return peerResult;
@@ -796,6 +975,18 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
796
975
  transcript_path: transcriptPath,
797
976
  sanitizer: { applied: [...sanResult.applied], removedSections: [...sanResult.removedSections] },
798
977
  });
978
+ // Plan 27.5-03 — feedback loop, turnCap=0 path. No
979
+ // SDK call was ever made, so no peer involvement and no model id was
980
+ // ever observed. Reward will be 0 (status !== 'completed') with cost 0.
981
+ _recordBanditOutcome({
982
+ agent: opts.stage,
983
+ bin: SESSION_RUNNER_DEFAULT_BIN,
984
+ delegate: banditIntegration.DELEGATE_NONE,
985
+ tier: tierFromModel(usage.model),
986
+ status,
987
+ costUsd: result.usage.usd_cost,
988
+ adaptiveMode,
989
+ });
799
990
  transcript.close();
800
991
  if (opts.signal !== undefined) opts.signal.removeEventListener('abort', onExternalAbort);
801
992
  return result;
@@ -890,6 +1081,21 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
890
1081
  transcript_path: transcriptPath,
891
1082
  sanitizer: { applied: [...sanResult.applied], removedSections: [...sanResult.removedSections] },
892
1083
  });
1084
+ // Plan 27.5-03 — feedback loop, terminal main-loop path.
1085
+ // This is the dominant write site: covers natural completion, budget cap,
1086
+ // turn cap (after first turn), abort, and error (post-retry-exhaustion).
1087
+ // Tier is inferred from the model actually observed during the run
1088
+ // (usage.model). Delegate=DELEGATE_NONE because tryDelegate either returned
1089
+ // null (we wouldn't be here otherwise) or wasn't invoked at all.
1090
+ _recordBanditOutcome({
1091
+ agent: opts.stage,
1092
+ bin: SESSION_RUNNER_DEFAULT_BIN,
1093
+ delegate: banditIntegration.DELEGATE_NONE,
1094
+ tier: tierFromModel(usage.model),
1095
+ status: result.status,
1096
+ costUsd: result.usage.usd_cost,
1097
+ adaptiveMode,
1098
+ });
893
1099
 
894
1100
  return result;
895
1101
  }
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: gdd-bandit-status
3
+ description: "Surface read-only per-(agent, bin, delegate) bandit posterior snapshot — alpha/beta/mean/stddev/count/last-used per arm. Phase 27.5 (v1.27.5) diagnostic. Use when investigating 'why did the bandit pick tier X for agent Y?' or when verifying posterior convergence after enabling adaptive_mode: full."
4
+ argument-hint: ""
5
+ tools: Read, Bash
6
+ ---
7
+
8
+ # gdd-bandit-status
9
+
10
+ ## Role
11
+
12
+ You are a deterministic, read-only diagnostic skill. You do not spawn agents and do not modify the bandit posterior. You read `.design/telemetry/posterior.json` (the path declared by `scripts/lib/bandit-router.cjs`'s `DEFAULT_POSTERIOR_PATH` constant), aggregate per-`(agent, bin, delegate, tier)` arm state, and emit a single Markdown table summarizing the posterior. The user runs this when they want to inspect bandit decisions without touching the posterior.
13
+
14
+ Strictly read-only per Phase 27.5 D-11. To reset the posterior, use `/gdd:bandit-reset` from Phase 23.5.
15
+
16
+ ## Invocation Contract
17
+
18
+ - **Input**: none. The skill takes no arguments.
19
+ - **Output**: a Markdown bandit-status table to stdout. No JSON wrapper. The table is the entire output.
20
+
21
+ ## Procedure
22
+
23
+ ### 1. Locate the posterior file
24
+
25
+ Read `.design/telemetry/posterior.json`. If the file does not exist:
26
+
27
+ - Emit the empty-state message:
28
+
29
+ ```
30
+ ## Bandit Posterior Snapshot
31
+
32
+ No posterior data yet — run a few pipeline cycles with `adaptive_mode: full` first.
33
+
34
+ No posterior data found at `.design/telemetry/posterior.json`.
35
+
36
+ Possible reasons:
37
+ - `adaptive_mode` is `static` or `hedge` (bandit is silent — see `.design/budget.json` `adaptive_mode` setting).
38
+ - No spawns have fired since Phase 27.5 wiring landed.
39
+ - Posterior was cleared via `/gdd:bandit-reset`.
40
+
41
+ See `reference/bandit-integration.md` for setup guidance.
42
+ ```
43
+
44
+ - Skip to Section 4 (Record).
45
+
46
+ ### 2. Parse the posterior
47
+
48
+ Parse the file as JSON. If parsing fails (truncated/corrupted file), emit:
49
+
50
+ ```
51
+ ## Bandit Posterior Snapshot
52
+
53
+ Posterior file at `.design/telemetry/posterior.json` exists but is unparseable (truncated or corrupted).
54
+
55
+ Run `/gdd:bandit-reset` to start fresh, or restore from a backup.
56
+ ```
57
+
58
+ The posterior schema is:
59
+
60
+ ```json
61
+ {
62
+ "schema_version": "1.0.0",
63
+ "generated_at": "<ISO timestamp>",
64
+ "arms": [
65
+ { "agent": "...", "bin": "...", "tier": "...", "delegate": "...", "alpha": N, "beta": N, "last_used": "...", "count": N }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ The `delegate` field is optional — when absent, the arm is the Phase 23.5 legacy slice (equivalent to `delegate: 'none'`). The status output renders `delegate: '-'` for legacy arms to distinguish them visually from explicit `'none'` arms.
71
+
72
+ ### 3. Render the table
73
+
74
+ Compute per arm:
75
+
76
+ - `mean = alpha / (alpha + beta)` (rounded to 3 decimals)
77
+ - `stddev = sqrt(alpha * beta / ((alpha + beta)^2 * (alpha + beta + 1)))` (rounded to 3 decimals)
78
+
79
+ Sort arms by `(agent ascending, bin ascending, delegate ascending where '-' sorts first, tier ascending where opus < sonnet < haiku is the canonical tier ordering, last_used descending tiebreaker)`. Group rows by agent for readability.
80
+
81
+ Emit:
82
+
83
+ ```
84
+ ## Bandit Posterior Snapshot
85
+
86
+ Per-(agent, bin, delegate, tier) posterior state. Read-only — to reset the posterior, use `/gdd:bandit-reset` (Phase 23.5).
87
+
88
+ Posterior file: `.design/telemetry/posterior.json` (last updated: <generated_at>)
89
+ Total arms: <count>
90
+
91
+ | Agent | Bin | Delegate | Tier | Alpha | Beta | Mean | Stddev | Count | Last Used |
92
+ |-----------------|--------|----------|--------|-------|-------|-------|--------|-------|----------------------|
93
+ | <agent> | <bin> | <deleg> | <tier> | <a> | <b> | <m> | <s> | <c> | <last_used or '-'> |
94
+
95
+ > Mean = alpha / (alpha + beta). Stddev = sqrt(alpha*beta / ((alpha+beta)^2 * (alpha+beta+1))).
96
+ > Delegate '-' = Phase 23.5 legacy slice (equivalent to 'none').
97
+ > See `reference/bandit-integration.md` for interpretation.
98
+ > Read-only — use `/gdd:bandit-reset` to clear posterior state.
99
+ ```
100
+
101
+ Format numbers to fixed precision: alpha/beta to 2 decimals, mean/stddev to 3 decimals, count as integer, last_used truncated to the minute precision (`YYYY-MM-DDTHH:MM`).
102
+
103
+ When `last_used` is null (arm exists but never selected — possible if the arm was created by `ensureArm` without a subsequent `pull`), render `-` in the Last Used column.
104
+
105
+ After the table, surface a brief best-arm summary per `(agent, bin)` slice — for each unique `(agent, bin)` pair, identify the arm with the highest `mean` (tie-broken by `count` descending) and display it as the "best-arm" recommendation. This helps the operator answer "why did the bandit pick tier X?" at a glance.
106
+
107
+ ### 4. Record
108
+
109
+ After execution, append one JSONL line to `.design/skill-records.jsonl`:
110
+
111
+ ```json
112
+ {"skill": "gdd-bandit-status", "ts": "<ISO timestamp>", "arms_seen": <count>, "posterior_present": <bool>}
113
+ ```
114
+
115
+ The skill writes ONLY to `.design/skill-records.jsonl` for telemetry purposes. It never touches `.design/telemetry/posterior.json`.
116
+
117
+ ## Cross-references
118
+
119
+ - `scripts/lib/bandit-router.cjs` (Phase 23.5) — posterior shape, `DEFAULT_POSTERIOR_PATH` constant, `loadPosterior()` helper.
120
+ - `scripts/lib/bandit-router/integration.cjs` (Phase 27.5-01) — production-integration shim.
121
+ - `hooks/budget-enforcer.ts` (Phase 27.5-02) — bandit consultation site.
122
+ - `scripts/lib/session-runner/index.ts` (Phase 27.5-03) — outcome recording site.
123
+ - `scripts/lib/bandit-arbitrage.cjs` (Phase 27.5-04) — automated stale-frontmatter analysis.
124
+ - `reference/bandit-integration.md` (Phase 27.5-06) — operator guide.
125
+ - `/gdd:bandit-reset` (Phase 23.5) — the ONLY surface that mutates the posterior.
126
+
127
+ ## Record
128
+
129
+ See Section 4 above.