axstack 0.49.1 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +5 -7
  2. package/docs/concepts.md +11 -51
  3. package/docs/host-operations.md +24 -99
  4. package/docs/installation.md +27 -52
  5. package/docs/workflows.md +48 -35
  6. package/package.json +1 -1
  7. package/profiles/presets/claude-only.json +0 -37
  8. package/profiles/presets/codex-only.json +0 -36
  9. package/profiles/presets/mixed.json +4 -41
  10. package/skills/axstack/references/automations.md +28 -2
  11. package/skills/axstack/references/autopilot.md +7 -5
  12. package/skills/axstack/references/candidate-publication.md +1 -0
  13. package/skills/axstack/references/contracts.md +55 -7
  14. package/skills/axstack/references/lifecycle.md +20 -5
  15. package/skills/axstack/references/pr-shape.md +15 -0
  16. package/skills/axstack/references/pr-triage-nightly.md +14 -2
  17. package/skills/axstack/references/review-manager-prompt.md +18 -11
  18. package/skills/axstack/references/role-roster.md +4 -9
  19. package/skills/axstack/references/routing.md +3 -7
  20. package/skills/axstack/references/t3-runtime.md +122 -103
  21. package/skills/axstack/references/test-audit-weekly.md +14 -2
  22. package/skills/axstack/references/workspace-hygiene.md +14 -2
  23. package/skills/axstack/scripts/dispatch-plan.js +10 -27
  24. package/skills/axstack/scripts/resolve-models.js +9 -18
  25. package/skills/axstack-brainstorm/references/arena.md +2 -2
  26. package/skills/axstack-cleanup/SKILL.md +6 -3
  27. package/skills/axstack-implement/SKILL.md +5 -4
  28. package/skills/axstack-research/SKILL.md +3 -5
  29. package/skills/axstack-review/SKILL.md +7 -3
  30. package/skills/axstack-scout/SKILL.md +2 -12
  31. package/skills/axstack-watch/SKILL.md +43 -8
  32. package/skills/axstack-watch/references/escalation.md +1 -1
  33. package/skills/axstack-watch/references/proven-verification.md +1 -1
  34. package/skills/axstack-watch/references/repair-publication.md +1 -1
  35. package/skills/axstack-watch/references/watch-runtime.md +46 -33
  36. package/src/instructions.js +4 -3
  37. package/src/roles.js +3 -6
  38. package/skills/axstack/scripts/account-usage.js +0 -79
  39. package/skills/axstack/scripts/pick-instance.js +0 -57
  40. package/skills/axstack/scripts/sync-default.js +0 -191
  41. package/skills/axstack/systemd/axstack-default-sync.service +0 -8
  42. package/skills/axstack/systemd/axstack-default-sync.timer +0 -11
@@ -1,191 +0,0 @@
1
- import * as fs from 'node:fs';
2
- import { accountUsage, expandHome } from './account-usage.js';
3
-
4
- const drivers = { claudeAgent: 'claude', codex: 'codex' };
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));
20
-
21
- export function decide(settings, usage, now, pool = null) {
22
- const from = settings.defaultModelSelection?.instanceId ?? null;
23
- const result = { action: 'unchanged', reason: 'unmanaged', from, to: from, instances: [] };
24
- const driver = settings.providerInstances?.[from]?.driver;
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]) => {
32
- const account = usage.find((entry) => entry.instanceId === instanceId);
33
- const window = instance.driver === 'claudeAgent' ? account?.windows?.[1]
34
- : account?.windows?.[1] ?? account?.windows?.[0];
35
- const excluded = account?.utilization >= 95 || account?.limitReached || account?.credentialsAbsent
36
- || (instanceId === from && (account?.unauthorized || account?.state === 'skipped'));
37
- const known = account && ['fresh', 'cached'].includes(account.state)
38
- && Number.isFinite(account.updatedAt) && now - account.updatedAt < 300000
39
- && Number.isFinite(window?.used) && Number.isFinite(window?.seconds) && window.seconds > 0
40
- && Number.isFinite(window?.resetAt) && window.resetAt > now
41
- && (!pooled || window.seconds >= 604800);
42
- const remaining = known ? 100 - window.used : null;
43
- return { instanceId, state: excluded ? 'excluded' : known ? 'eligible' : 'unknown',
44
- ...(pooled && { band: known ? remaining >= 15 ? 2 : remaining >= 10 ? 1 : 0 : null }),
45
- pace: known ? 100 * (1 - (window.resetAt - now) / (window.seconds * 1000)) - window.used : null };
46
- });
47
- const current = result.instances.find((entry) => entry.instanceId === from);
48
- const best = result.instances.filter((entry) => entry.state === 'eligible')
49
- .sort((a, b) => (pooled ? b.band - a.band : 0) || b.pace - a.pace)[0];
50
- const excluded = !current || current.state === 'excluded';
51
- if (excluded && best) return updated(best, 'current-excluded');
52
- if (result.instances.some((entry) => entry.state === 'unknown')) return { ...result, reason: 'unknown' };
53
- if (!best) return { ...result, reason: 'no-eligible' };
54
- const higherBand = pooled && best.band > current.band;
55
- if (best.instanceId !== from && (higherBand || best.pace - current.pace >= 10)) {
56
- return updated(best, higherBand ? 'reserve' : 'pace');
57
- }
58
- return { ...result, reason: 'margin' };
59
- }
60
-
61
- // Preserve the instance-only edit when the rest of the selection is already identical.
62
- function selectionBytes(original, selection) {
63
- const previous = JSON.parse(original).defaultModelSelection;
64
- const instanceOnly = deepEqual({ ...previous, instanceId: selection.instanceId }, selection);
65
- let start;
66
- const tokens = [...original.matchAll(/"(?:\\.|[^"\\])*"|[{}\[\]:,]|[^\s{}\[\]:,]+/g)];
67
- let depth = 0;
68
- let selectionDepth = 0;
69
- for (let i = 0; i < tokens.length; i++) {
70
- const token = tokens[i][0];
71
- if (depth === 1 && token.startsWith('"') && JSON.parse(token) === 'defaultModelSelection'
72
- && tokens[i + 1]?.[0] === ':' && tokens[i + 2]?.[0] === '{') {
73
- selectionDepth = depth + 1;
74
- start = tokens[i + 2].index;
75
- }
76
- if (instanceOnly && selectionDepth && depth === selectionDepth && token.startsWith('"') && JSON.parse(token) === 'instanceId'
77
- && tokens[i + 1]?.[0] === ':') {
78
- const value = tokens[i + 2];
79
- return original.slice(0, value.index) + JSON.stringify(selection.instanceId) + original.slice(value.index + value[0].length);
80
- }
81
- if (token === '{' || token === '[') depth++;
82
- if (token === '}' || token === ']') {
83
- if (depth === selectionDepth) {
84
- if (!instanceOnly) return original.slice(0, start) + JSON.stringify(selection) + original.slice(tokens[i].index + 1);
85
- selectionDepth = 0;
86
- }
87
- depth--;
88
- }
89
- }
90
- throw new Error('missing instance string');
91
- }
92
-
93
- function deepEqual(a, b) {
94
- if (a === b) return true;
95
- if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
96
- const keys = Object.keys(a);
97
- return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
98
- }
99
-
100
- export function commit(path, originalBytes, nextBytes, selection) {
101
- const original = JSON.parse(originalBytes);
102
- const next = JSON.parse(nextBytes);
103
- const from = original.defaultModelSelection?.instanceId;
104
- const to = next.defaultModelSelection?.instanceId;
105
- if (typeof from !== 'string' || typeof to !== 'string') throw new Error('invalid instance string');
106
- selection ??= { ...original.defaultModelSelection, instanceId: to };
107
- if (!deepEqual(next.defaultModelSelection, selection)) throw new Error('refusing undecided selection');
108
- const unchanged = deepEqual(original.defaultModelSelection, selection);
109
- original.defaultModelSelection = selection;
110
- if (!deepEqual(original, next)) throw new Error('refusing change outside defaultModelSelection');
111
- if (unchanged) return 'unchanged';
112
- const lock = `${path}.axstack-default-sync.lock`;
113
- try { fs.mkdirSync(lock, { mode: 0o700 }); }
114
- catch (error) {
115
- if (error.code !== 'EEXIST') throw error;
116
- if (Date.now() - fs.lstatSync(lock).mtimeMs <= 60000) return 'locked';
117
- fs.rmdirSync(lock);
118
- try { fs.mkdirSync(lock, { mode: 0o700 }); }
119
- catch (retryError) { if (retryError.code === 'EEXIST') return 'locked'; throw retryError; }
120
- }
121
- let temp;
122
- let fd;
123
- try {
124
- const info = fs.lstatSync(path);
125
- if (!info.isFile() || info.isSymbolicLink()) throw new Error('settings must be a regular file');
126
- const sibling = `${path}.axstack-default-sync-${crypto.randomUUID()}.tmp`;
127
- fd = fs.openSync(sibling, 'wx', info.mode & 0o7777);
128
- temp = sibling;
129
- fs.fchmodSync(fd, info.mode & 0o7777);
130
- fs.writeFileSync(fd, nextBytes);
131
- fs.fsyncSync(fd);
132
- fs.closeSync(fd);
133
- fd = undefined;
134
- // This is the final operation before rename; the remaining sub-ms race is accepted.
135
- if (!fs.readFileSync(path).equals(Buffer.from(originalBytes))) return 'concurrent-edit';
136
- fs.renameSync(temp, path);
137
- temp = undefined;
138
- if (!fs.readFileSync(path).equals(Buffer.from(nextBytes))) throw new Error('read-back mismatch');
139
- return 'updated';
140
- } finally {
141
- try { if (fd !== undefined) fs.closeSync(fd); }
142
- finally {
143
- try { if (temp) fs.unlinkSync(temp); }
144
- finally { fs.rmdirSync(lock); }
145
- }
146
- }
147
- }
148
-
149
- async function main() {
150
- if (process.env.AXSTACK_SYNC_DEFAULT === '0') {
151
- console.log(JSON.stringify({ action: 'unchanged', reason: 'disabled', from: null, to: null, instances: [] }));
152
- return;
153
- }
154
- let path = `${process.env.HOME}/.t3/userdata/settings.json`;
155
- let dryRun = false;
156
- const args = process.argv.slice(2);
157
- const seen = new Set();
158
- for (let i = 0; i < args.length; i++) {
159
- if (seen.has(args[i])) throw new Error('duplicate flag');
160
- seen.add(args[i]);
161
- if (args[i] === '--dry-run') dryRun = true;
162
- else if (args[i] === '--settings' && args[i + 1] && !args[i + 1].startsWith('--')) path = args[++i];
163
- else throw new Error('expected [--settings path] [--dry-run]');
164
- }
165
- path = expandHome(path);
166
- const original = fs.readFileSync(path, 'utf8');
167
- const settings = JSON.parse(original);
168
- if (!settings || typeof settings !== 'object' || Array.isArray(settings)
169
- || (Object.hasOwn(settings, 'providerInstances') && (!settings.providerInstances
170
- || typeof settings.providerInstances !== 'object' || Array.isArray(settings.providerInstances)))) throw new Error('invalid settings');
171
- const driver = settings.providerInstances?.[settings.defaultModelSelection?.instanceId]?.driver;
172
- const pool = parsePool(process.env.AXSTACK_SYNC_POOL);
173
- const usage = [];
174
- if (Object.hasOwn(drivers, driver) && (!pool || Object.hasOwn(pool, driver))) {
175
- for (const [id, instance] of candidates(settings, pool ?? { [driver]: null })) {
176
- usage.push(await accountUsage(id, instance, drivers[instance.driver]));
177
- }
178
- }
179
- let decision = decide(settings, usage, Date.now(), pool);
180
- if (decision.action === 'updated' && !dryRun) {
181
- const reason = commit(path, original, selectionBytes(original, decision.selection), decision.selection);
182
- if (reason !== 'updated') decision = { ...decision, action: 'unchanged', reason, to: decision.from };
183
- }
184
- console.log(JSON.stringify(decision));
185
- }
186
-
187
- if (import.meta.main) main().catch((error) => {
188
- console.error(error.message === 'read-back mismatch' ? 'default sync failed: read-back mismatch'
189
- : 'default sync failed: invalid input/settings or failed write');
190
- process.exitCode = 1;
191
- });
@@ -1,8 +0,0 @@
1
- [Unit]
2
- Description=Keep the T3 New-chat default on the most abundant account
3
-
4
- [Service]
5
- Type=oneshot
6
- Environment=PATH=%h/.bun/bin:/usr/local/bin:/usr/bin:/bin
7
- UMask=0077
8
- ExecStart=/usr/bin/env bun %h/.agents/skills/axstack/scripts/sync-default.js
@@ -1,11 +0,0 @@
1
- [Unit]
2
- Description=Refresh the T3 New-chat account default every ten minutes
3
-
4
- [Timer]
5
- OnStartupSec=1min
6
- OnUnitActiveSec=10min
7
- Persistent=true
8
- Unit=axstack-default-sync.service
9
-
10
- [Install]
11
- WantedBy=timers.target