axstack 0.29.0 → 0.30.1

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/README.md CHANGED
@@ -15,7 +15,7 @@ coordination. You can start at the phase you need.
15
15
  | Failure mode | How Axstack responds |
16
16
  | --- | --- |
17
17
  | Wrong thing built | Align rounds clarify the request; every brainstorm runs a light arena across configured families. Use judges only for Rung 2 hard-to-reverse choices. |
18
- | Nobody really reviewed it | Strict TDD checks behavior first; mixed authored review is cross-provider, and mixed peer review uses two isolated Sol-high sessions at the exact revision. |
18
+ | Nobody really reviewed it | Strict TDD checks behavior first; mixed authored review is cross-provider, and mixed peer review pairs Sol high with Opus low in isolated sessions at the exact revision. |
19
19
  | Design rot | The design lens sketches boundaries before a build; Improve surfaces evidenced changes later. |
20
20
  | Agents left a mess | T3 makes delegation visible, one writer owns each PR, cleanup stays bounded, and watched own PRs merge under guarded rules. |
21
21
 
@@ -126,7 +126,7 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
126
126
 
127
127
  - T3 Code is the only supported active runtime. Axstack adds no daemon or runtime
128
128
  database. The exception is one opt-in systemd user timer `axstack-default-sync`,
129
- running a stateless script that rewrites only T3 `defaultModelSelection.instanceId`.
129
+ running a stateless script that rewrites only T3 `defaultModelSelection`.
130
130
  It has no workflow state and launches no agent; see
131
131
  [Host operations](docs/host-operations.md#new-chat-default-sync).
132
132
  - The recorded owning watch thread merges eligible own PRs; excluded PRs use a merge card.
package/docs/concepts.md CHANGED
@@ -103,7 +103,7 @@ and effort remain fixed. Dispatched roles never fail over mid-thread.
103
103
  See [Provider bindings](../skills/axstack/references/t3-runtime.md#preflight-and-binding)
104
104
  for schedule rebinding and the [policy carve-out](workflows.md#automatic-merge-boundaries).
105
105
 
106
- The opt-in `sync-default.js` keeps the New-chat default on the same-provider account with the highest elapsed-minus-used pace, switching at a ten-point lead or when the current account is excluded; it preserves the model and project overrides.
106
+ The opt-in `sync-default.js` rotates the New-chat default by elapsed-minus-used pace at a ten-point lead or current exclusion. `AXSTACK_SYNC_POOL` enables cross-driver weekly comparison and model/effort selection; without it, only the same-driver account changes.
107
107
 
108
108
  ## Account selection environment
109
109
 
@@ -46,7 +46,7 @@ executor MCP, and live schedule behavior need separate preflights.
46
46
  Installation creates no production schedule and never enables a timer.
47
47
  Axstack allows one opt-in systemd user timer `axstack-default-sync` as the
48
48
  scheduler exception: it runs a stateless script that rewrites only T3
49
- `defaultModelSelection.instanceId`, has no workflow state and launches no agent.
49
+ `defaultModelSelection`, has no workflow state and launches no agent.
50
50
  Every verified own-PR publication arms or joins the driver's chat-run watch.
51
51
  Its bound T3 schedule uses the cadence in [Chat-run watch activation](#chat-run-watch-activation).
52
52
  See [Chat-run PR watch](workflows.md#chat-run-pr-watch) for authority, schedule identity and stop conditions.
@@ -178,30 +178,34 @@ canary described by the operational contract.
178
178
 
179
179
  ## New-chat default sync
180
180
 
181
- This opt-in stateless script rewrites only `defaultModelSelection.instanceId`
182
- in `~/.t3/userdata/settings.json`. It compares enabled accounts of the current
183
- Claude/Codex driver, without plan weights; it keeps the model, project overrides
184
- and every other settings byte. It never creates a missing default or refreshes
185
- OAuth tokens. Unknown usage or a missing reset clock holds the default, unless
186
- an excluded/unauthorized current account can escape to a known eligible sibling.
187
- The shared usage cache expires after five minutes; sync refuses stale usage for
188
- comparison. Any session/weekly window at 95% or a reached limit is excluded.
189
-
190
- Preview the decision and save the current instance ID for rollback:
181
+ This opt-in script changes only root `defaultModelSelection` in
182
+ `~/.t3/userdata/settings.json`. Without a pool it changes only the instance
183
+ among enabled accounts of the current Claude/Codex driver.
184
+ `AXSTACK_SYNC_POOL` compares drivers by weekly clocks (>=604800s), without
185
+ plan weights. Rotation needs a ten-point elapsed-minus-used pace lead;
186
+ excluded/unauthorized/skipped current accounts can escape to known candidates.
187
+ Any window at 95% or a reached limit excludes. Non-current absent credentials
188
+ exclude; malformed/unreadable credentials and unknown/stale usage hold.
189
+ Cache expires after five minutes. Missing defaults stay untouched; OAuth tokens
190
+ are never refreshed. Each host's pool uses only enabled, credentialed drivers:
191
191
 
192
192
  ```sh
193
- bun ~/.agents/skills/axstack/scripts/sync-default.js --dry-run
193
+ AXSTACK_SYNC_POOL=claudeAgent=claude-opus-5-5,codex=gpt-6.1-sol:xhigh \
194
+ bun ~/.agents/skills/axstack/scripts/sync-default.js --dry-run
194
195
  ```
195
196
 
196
- Exit 0 prints one token-free JSON decision line; dry-run reports the proposed
197
- change without writing. `--settings <path>` selects a fixture or alternate file.
198
- `AXSTACK_SYNC_DEFAULT=0` is a no-op. Exit 1 reports malformed settings or a failed
199
- write. A sibling temp file preserves the settings mode and is fsynced before
200
- rename. The script checks original bytes immediately before rename and verifies
201
- bytes afterward. The accepted sub-ms race with an in-app T3 write can still lose
202
- that edit; a read-back mismatch is logged as an error rather than overwritten.
197
+ Exit 0 prints one token-free JSON decision with proposed selection; dry-run
198
+ never writes. `--settings <path>` selects another file. Pool entries are
199
+ `driver=model[:effort]`: Claude `effort` or Codex `reasoningEffort` options;
200
+ omitted effort omits options. Rotation writes the full pool selection,
201
+ overwriting manual model/effort picks only then. Pin with `AXSTACK_SYNC_DEFAULT=0`
202
+ or a default driver outside the pool. Exit 1 means invalid pool/settings or write
203
+ failure. Save the full selection for rollback. Other bytes stay intact.
204
+ A fsynced sibling preserves mode; original bytes are checked before rename and
205
+ read back afterward. The accepted sub-ms T3 write race can lose an edit;
206
+ read-back mismatch is an error.
203
207
 
204
- After recording host-mutation authority, copy the shipped units and enable:
208
+ Under host-mutation authority, install and enable:
205
209
 
206
210
  ```sh
207
211
  mkdir -p ~/.config/systemd/user
@@ -213,18 +217,21 @@ systemctl --user list-timers axstack-default-sync.timer
213
217
  journalctl --user -u axstack-default-sync.service
214
218
  ```
215
219
 
216
- The first tick is about one minute after user-manager startup, then every ten
217
- minutes. `Persistent=true` is shipped; this monotonic timer does not replay
218
- missed calendar ticks. For a different skills root or Bun path, override
219
- `ExecStart`/`Environment` with `systemctl --user edit axstack-default-sync.service`.
220
- Operation after logout requires an authorized user-manager/linger setup.
221
- Installation never activates these units.
220
+ The timer starts about one minute after user-manager startup, then every ten
221
+ minutes. Its monotonic `Persistent=true` timer never replays calendar ticks.
222
+ Override Bun/skills paths via `ExecStart`/`Environment` in
223
+ `systemctl --user edit axstack-default-sync.service`. Add the per-host pool:
224
+
225
+ ```ini
226
+ [Service]
227
+ Environment=AXSTACK_SYNC_POOL=claudeAgent=claude-opus-5-5,codex=gpt-6.1-sol:xhigh
228
+ ```
229
+
230
+ Reload after edits. Logout needs authorized linger; installation never activates units.
222
231
 
223
232
  Disable with `systemctl --user disable --now axstack-default-sync.timer` and
224
233
  stop any active one-shot with `systemctl --user stop axstack-default-sync.service`.
225
- For rollback, then restore the saved default instance through T3 Settings.
226
- The exclusive sibling directory lock `<settings>.axstack-default-sync.lock`
227
- prevents overlapping commits; fresh-lock contention prints unchanged/locked.
228
- A lock older than sixty seconds is treated as stale: the script removes that
229
- exact empty directory and retries acquisition once, recovering on a later tick
230
- from a killed writer. If the retry finds a fresh lock, it still prints locked.
234
+ Rollback: remove the pool drop-in, reload, and restore the full selection in T3 Settings.
235
+ The sibling `<settings>.axstack-default-sync.lock` directory prevents overlap
236
+ (unchanged/locked). Empty locks older than sixty seconds are removed and retried
237
+ once; fresh contention reports locked.
@@ -93,7 +93,7 @@ for the picker cache directory and test-only usage endpoint variables.
93
93
  Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
94
94
  `resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
95
95
  `pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
96
- `sync-default.js [--settings <path>] [--dry-run]` (New-chat account default).
96
+ `sync-default.js [--settings <path>] [--dry-run]` (New-chat default; `AXSTACK_SYNC_POOL` opts into cross-driver rotation).
97
97
  `account-usage.js` shares usage retrieval and the five-minute cache between
98
98
  picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
99
99
  installation copies them as assets and never enables the timer.
package/docs/workflows.md CHANGED
@@ -119,7 +119,7 @@ role.
119
119
 
120
120
  | Preset | Author | Ordered peer reviewers | Astra / Opus advisers | Auditor |
121
121
  | --- | --- | --- | --- | --- |
122
- | `mixed` | Sol high | Sol high; Sol high | Astra high / Opus xhigh | Sonnet high + Sol high |
122
+ | `mixed` | Sol high | Sol high; Opus low | Astra high / Opus xhigh | Sonnet high + Sol high |
123
123
  | `codex-only` | Sol high | Sol high; Luna xhigh | Astra high / unavailable | Luna xhigh + Sol high |
124
124
  | `claude-only` | Opus medium | Opus medium; Sonnet high | unavailable / Opus xhigh | Sonnet high (Sol absent) |
125
125
 
@@ -147,7 +147,8 @@ later installation changes without re-resolution.
147
147
 
148
148
  Peer roles use the stable IDs `axstack-reviewer-primary` and
149
149
  `axstack-reviewer-peer`; their provider/class mappings come only from the
150
- selected preset. The mixed peer coordinator and review-lane binding
150
+ selected preset. The mixed cross-provider peer pair is Sol high followed by
151
+ Opus low. The mixed peer coordinator and review-lane binding
151
152
  `axstack-owner` uses Sol high and never reviews. `axstack-reviewer-secondary`
152
153
  is used in the authored mapping and, alongside `axstack-reviewer-primary`,
153
154
  in codebase findings mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks T3 Code capabilities.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -43,7 +43,7 @@
43
43
  "provider": "codex",
44
44
  "modeId": "full-access",
45
45
  "thinkingOptionId": "high",
46
- "notes": "Primary reviewer in the ordered mixed peer pair: Sol high followed by Sol high. Eligible authored reviewer for an Opus-authored candidate. Never author or owner; review exact SHA and base across all six angles and acceptance. Peer first pass stays isolated.",
46
+ "notes": "Primary reviewer in the ordered mixed peer pair: Sol high followed by Opus low. Eligible authored reviewer for an Opus-authored candidate. Never author or owner; review exact SHA and base across all six angles and acceptance. Peer first pass stays isolated.",
47
47
  "modelClass": "sol"
48
48
  },
49
49
  {
@@ -58,11 +58,11 @@
58
58
  {
59
59
  "id": "axstack-reviewer-peer",
60
60
  "name": "Axstack reviewer (peer)",
61
- "provider": "codex",
62
- "modeId": "full-access",
63
- "thinkingOptionId": "high",
64
- "notes": "Second reviewer in the ordered mixed peer pair: Sol high followed by Sol high. Never author or owner; review exact SHA and base across all six angles and acceptance. Peer first pass stays isolated.",
65
- "modelClass": "sol"
61
+ "provider": "claude",
62
+ "modeId": "bypassPermissions",
63
+ "thinkingOptionId": "low",
64
+ "notes": "Second reviewer in the ordered mixed peer pair: Sol high followed by Opus low. Never author or owner; review exact SHA and base across all six angles and acceptance. Peer first pass stays isolated. T3 resolves Claude classes from saved capabilities; a Claude rejection holds.",
65
+ "modelClass": "opus"
66
66
  },
67
67
  {
68
68
  "id": "axstack-diligence",
@@ -48,6 +48,8 @@ Never infer any other route from quota state or subscription entitlement. Every
48
48
  substitution requires the user's decision: configured alternatives are not
49
49
  defaults. Rejection, timeout, quota and auth failures hold affected work.
50
50
  Substitution here means changing provider or model, excluding that account selection.
51
+ Exception: opt-in default sync can change only the New-chat default within the user-configured pool.
52
+ Running threads, dispatched roles, schedules and roles.json keep existing rules.
51
53
  Read the [T3 runtime boundary](t3-runtime.md) before dispatch, receipt consumption
52
54
  or recovery; resolve models from the saved capabilities snapshot and verify
53
55
  requested and effective settings separately.
@@ -7,7 +7,8 @@
7
7
  Only the recorded owning watch thread merges, including `axstack-owner`
8
8
  for authorized standalone maintenance. Other roles never acquire merge authority.
9
9
  - `axstack-reviewer-primary` and `axstack-reviewer-peer` are the ordered
10
- peer pair. Peer review uses both; authored review uses this table:
10
+ peer pair. In `mixed`, the cross-provider pair is Sol high followed by Opus low.
11
+ Peer review uses both; authored review uses this table:
11
12
 
12
13
  | Preset | Author class | Reviewer (class/effort) |
13
14
  | --- | --- | --- |
@@ -33,8 +33,9 @@ export async function accountUsage(instanceId, instance, provider) {
33
33
  const valid = provider === 'claude' ? credentials.claudeAiOauth?.accessToken
34
34
  : credentials.tokens?.access_token && credentials.tokens?.account_id;
35
35
  if (!valid) throw new Error();
36
- } catch {
37
- return { instanceId, state: 'skipped', reason: 'missing credentials' };
36
+ } catch (error) {
37
+ return { instanceId, state: 'skipped', reason: 'missing credentials',
38
+ credentialsAbsent: error.code === 'ENOENT' };
38
39
  }
39
40
  const headers = provider === 'claude'
40
41
  ? { authorization: `Bearer ${credentials.claudeAiOauth.accessToken}`,
@@ -2,55 +2,86 @@ import * as fs from 'node:fs';
2
2
  import { accountUsage, expandHome } from './account-usage.js';
3
3
 
4
4
  const drivers = { claudeAgent: 'claude', codex: 'codex' };
5
- const candidates = (settings) => Object.entries(settings.providerInstances).filter(([, instance]) =>
6
- instance.enabled && instance.driver === settings.providerInstances[settings.defaultModelSelection.instanceId]?.driver);
5
+ function parsePool(value) {
6
+ if (!value) return null;
7
+ const pool = {};
8
+ for (const entry of value.split(',')) {
9
+ const match = /^(claudeAgent|codex)=([^:,=\s]+)(?::([a-z]+))?$/.exec(entry);
10
+ if (!match || Object.hasOwn(pool, match[1])) throw new Error('invalid pool');
11
+ const [, driver, model, effort] = match;
12
+ pool[driver] = { model, ...(effort && { options: [
13
+ { id: driver === 'codex' ? 'reasoningEffort' : 'effort', value: effort },
14
+ ] }) };
15
+ }
16
+ return pool;
17
+ }
18
+ const candidates = (settings, pool) => Object.entries(settings.providerInstances ?? {}).filter(([, instance]) =>
19
+ instance.enabled && Object.hasOwn(pool, instance.driver));
7
20
 
8
- export function decide(settings, usage, now) {
21
+ export function decide(settings, usage, now, pool = null) {
9
22
  const from = settings.defaultModelSelection?.instanceId ?? null;
10
23
  const result = { action: 'unchanged', reason: 'unmanaged', from, to: from, instances: [] };
11
24
  const driver = settings.providerInstances?.[from]?.driver;
12
- if (!Object.hasOwn(drivers, driver)) return result;
13
- result.instances = candidates(settings).map(([instanceId]) => {
25
+ const pooled = pool !== null;
26
+ pool ??= { [driver]: null };
27
+ if (!Object.hasOwn(drivers, driver) || !Object.hasOwn(pool, driver)) return result;
28
+ const updated = (best, reason) => ({ ...result, action: 'updated', reason, to: best.instanceId,
29
+ selection: pooled ? { instanceId: best.instanceId, ...pool[settings.providerInstances[best.instanceId].driver] }
30
+ : { ...settings.defaultModelSelection, instanceId: best.instanceId } });
31
+ result.instances = candidates(settings, pool).map(([instanceId, instance]) => {
14
32
  const account = usage.find((entry) => entry.instanceId === instanceId);
15
- const window = driver === 'claudeAgent' ? account?.windows?.[1]
33
+ const window = instance.driver === 'claudeAgent' ? account?.windows?.[1]
16
34
  : account?.windows?.[1] ?? account?.windows?.[0];
17
- const excluded = account?.utilization >= 95 || account?.limitReached
35
+ const excluded = account?.utilization >= 95 || account?.limitReached || account?.credentialsAbsent
18
36
  || (instanceId === from && (account?.unauthorized || account?.state === 'skipped'));
19
37
  const known = account && ['fresh', 'cached'].includes(account.state)
20
38
  && Number.isFinite(account.updatedAt) && now - account.updatedAt < 300000
21
39
  && Number.isFinite(window?.used) && Number.isFinite(window?.seconds) && window.seconds > 0
22
- && Number.isFinite(window?.resetAt) && window.resetAt > now;
40
+ && Number.isFinite(window?.resetAt) && window.resetAt > now
41
+ && (!pooled || window.seconds >= 604800);
23
42
  return { instanceId, state: excluded ? 'excluded' : known ? 'eligible' : 'unknown',
24
43
  pace: known ? 100 * (1 - (window.resetAt - now) / (window.seconds * 1000)) - window.used : null };
25
44
  });
26
45
  const current = result.instances.find((entry) => entry.instanceId === from);
27
46
  const best = result.instances.filter((entry) => entry.state === 'eligible').sort((a, b) => b.pace - a.pace)[0];
28
47
  const excluded = !current || current.state === 'excluded';
29
- if (excluded && best) return { ...result, action: 'updated', reason: 'current-excluded', to: best.instanceId };
48
+ if (excluded && best) return updated(best, 'current-excluded');
30
49
  if (result.instances.some((entry) => entry.state === 'unknown')) return { ...result, reason: 'unknown' };
31
50
  if (!best) return { ...result, reason: 'no-eligible' };
32
51
  if (best.instanceId !== from && best.pace - current.pace >= 10) {
33
- return { ...result, action: 'updated', reason: 'pace', to: best.instanceId };
52
+ return updated(best, 'pace');
34
53
  }
35
54
  return { ...result, reason: 'margin' };
36
55
  }
37
56
 
38
- // Locate only the root selection's instance string, leaving every other byte intact.
39
- function selectionBytes(original, instanceId) {
57
+ // Preserve the instance-only edit when the rest of the selection is already identical.
58
+ function selectionBytes(original, selection) {
59
+ const previous = JSON.parse(original).defaultModelSelection;
60
+ const instanceOnly = deepEqual({ ...previous, instanceId: selection.instanceId }, selection);
61
+ let start;
40
62
  const tokens = [...original.matchAll(/"(?:\\.|[^"\\])*"|[{}\[\]:,]|[^\s{}\[\]:,]+/g)];
41
63
  let depth = 0;
42
64
  let selectionDepth = 0;
43
65
  for (let i = 0; i < tokens.length; i++) {
44
66
  const token = tokens[i][0];
45
67
  if (depth === 1 && token.startsWith('"') && JSON.parse(token) === 'defaultModelSelection'
46
- && tokens[i + 1]?.[0] === ':' && tokens[i + 2]?.[0] === '{') selectionDepth = depth + 1;
47
- if (selectionDepth && depth === selectionDepth && token.startsWith('"') && JSON.parse(token) === 'instanceId'
68
+ && tokens[i + 1]?.[0] === ':' && tokens[i + 2]?.[0] === '{') {
69
+ selectionDepth = depth + 1;
70
+ start = tokens[i + 2].index;
71
+ }
72
+ if (instanceOnly && selectionDepth && depth === selectionDepth && token.startsWith('"') && JSON.parse(token) === 'instanceId'
48
73
  && tokens[i + 1]?.[0] === ':') {
49
74
  const value = tokens[i + 2];
50
- return original.slice(0, value.index) + JSON.stringify(instanceId) + original.slice(value.index + value[0].length);
75
+ return original.slice(0, value.index) + JSON.stringify(selection.instanceId) + original.slice(value.index + value[0].length);
51
76
  }
52
77
  if (token === '{' || token === '[') depth++;
53
- if (token === '}' || token === ']') { if (depth === selectionDepth) selectionDepth = 0; depth--; }
78
+ if (token === '}' || token === ']') {
79
+ if (depth === selectionDepth) {
80
+ if (!instanceOnly) return original.slice(0, start) + JSON.stringify(selection) + original.slice(tokens[i].index + 1);
81
+ selectionDepth = 0;
82
+ }
83
+ depth--;
84
+ }
54
85
  }
55
86
  throw new Error('missing instance string');
56
87
  }
@@ -62,15 +93,18 @@ function deepEqual(a, b) {
62
93
  return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
63
94
  }
64
95
 
65
- export function commit(path, originalBytes, nextBytes) {
96
+ export function commit(path, originalBytes, nextBytes, selection) {
66
97
  const original = JSON.parse(originalBytes);
67
98
  const next = JSON.parse(nextBytes);
68
99
  const from = original.defaultModelSelection?.instanceId;
69
100
  const to = next.defaultModelSelection?.instanceId;
70
101
  if (typeof from !== 'string' || typeof to !== 'string') throw new Error('invalid instance string');
71
- original.defaultModelSelection.instanceId = to;
72
- if (!deepEqual(original, next)) throw new Error('refusing non-instanceId change');
73
- if (from === to) return 'unchanged';
102
+ selection ??= { ...original.defaultModelSelection, instanceId: to };
103
+ if (!deepEqual(next.defaultModelSelection, selection)) throw new Error('refusing undecided selection');
104
+ const unchanged = deepEqual(original.defaultModelSelection, selection);
105
+ original.defaultModelSelection = selection;
106
+ if (!deepEqual(original, next)) throw new Error('refusing change outside defaultModelSelection');
107
+ if (unchanged) return 'unchanged';
74
108
  const lock = `${path}.axstack-default-sync.lock`;
75
109
  try { fs.mkdirSync(lock, { mode: 0o700 }); }
76
110
  catch (error) {
@@ -131,12 +165,16 @@ async function main() {
131
165
  || (Object.hasOwn(settings, 'providerInstances') && (!settings.providerInstances
132
166
  || typeof settings.providerInstances !== 'object' || Array.isArray(settings.providerInstances)))) throw new Error('invalid settings');
133
167
  const driver = settings.providerInstances?.[settings.defaultModelSelection?.instanceId]?.driver;
134
- const provider = Object.hasOwn(drivers, driver) ? drivers[driver] : null;
168
+ const pool = parsePool(process.env.AXSTACK_SYNC_POOL);
135
169
  const usage = [];
136
- if (provider) for (const [id, instance] of candidates(settings)) usage.push(await accountUsage(id, instance, provider));
137
- let decision = decide(settings, usage, Date.now());
170
+ if (Object.hasOwn(drivers, driver) && (!pool || Object.hasOwn(pool, driver))) {
171
+ for (const [id, instance] of candidates(settings, pool ?? { [driver]: null })) {
172
+ usage.push(await accountUsage(id, instance, drivers[instance.driver]));
173
+ }
174
+ }
175
+ let decision = decide(settings, usage, Date.now(), pool);
138
176
  if (decision.action === 'updated' && !dryRun) {
139
- const reason = commit(path, original, selectionBytes(original, decision.to));
177
+ const reason = commit(path, original, selectionBytes(original, decision.selection), decision.selection);
140
178
  if (reason !== 'updated') decision = { ...decision, action: 'unchanged', reason, to: decision.from };
141
179
  }
142
180
  console.log(JSON.stringify(decision));
@@ -223,7 +223,8 @@ This section applies to peer and authored PR modes.
223
223
  - **Peer:** exactly two independent final reviewers,
224
224
  `axstack-reviewer-primary` and `axstack-reviewer-peer` from the routing snapshot.
225
225
  Send both the identical six-angle brief, with no first-pass cross-read or children.
226
- Mixed peer reviewers share a model, with independence from separate sessions, the identical brief and an isolated first pass.
226
+ The mixed peer pair is cross-provider (Sol high + Opus low), with independence
227
+ still from separate sessions, the identical brief and an isolated first pass.
227
228
  - **Authored:** exactly one eligible independent reviewer from this complete
228
229
  mapping:
229
230