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 +2 -2
- package/docs/concepts.md +1 -1
- package/docs/host-operations.md +39 -32
- package/docs/installation.md +1 -1
- package/docs/workflows.md +3 -2
- package/package.json +1 -1
- package/profiles/presets/mixed.json +6 -6
- package/skills/axstack/references/contracts.md +2 -0
- package/skills/axstack/references/role-roster.md +2 -1
- package/skills/axstack/scripts/account-usage.js +3 -2
- package/skills/axstack/scripts/sync-default.js +62 -24
- package/skills/axstack-review/SKILL.md +2 -1
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
|
|
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
|
|
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`
|
|
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
|
|
package/docs/host-operations.md
CHANGED
|
@@ -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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
minutes. `Persistent=true`
|
|
218
|
-
|
|
219
|
-
`
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
The
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
package/docs/installation.md
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
|
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.
|
|
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
|
|
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": "
|
|
62
|
-
"modeId": "
|
|
63
|
-
"thinkingOptionId": "
|
|
64
|
-
"notes": "Second reviewer in the ordered mixed peer pair: Sol high followed by
|
|
65
|
-
"modelClass": "
|
|
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.
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
52
|
+
return updated(best, 'pace');
|
|
34
53
|
}
|
|
35
54
|
return { ...result, reason: 'margin' };
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
//
|
|
39
|
-
function selectionBytes(original,
|
|
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] === '{')
|
|
47
|
-
|
|
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 === ']') {
|
|
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
|
|
72
|
-
if (!deepEqual(
|
|
73
|
-
|
|
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
|
|
168
|
+
const pool = parsePool(process.env.AXSTACK_SYNC_POOL);
|
|
135
169
|
const usage = [];
|
|
136
|
-
if (
|
|
137
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|