axstack 0.50.2 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -174,3 +174,7 @@ Regenerate `bun.lock` with the minimum Bun version in `package.json.engines`
174
174
  ## License
175
175
 
176
176
  [MIT](LICENSE) © 2026 Axat Bhardwaj.
177
+
178
+ The [opt-in saved New-chat default script](skills/axstack/references/default-routing.md)
179
+ uses subscription capacity and preserves project overrides; its five-minute timer
180
+ is never enabled by installation. Role-labelled title instructions need T3 >=0.1.3.
@@ -294,10 +294,11 @@ missing either holds this role without substitution, including Haiku 4.5.
294
294
  The `codex-only` assignments for these roles are unchanged.
295
295
  Scout adds `axstack-explore-codebase-luna` at Codex Luna xhigh in `mixed` and
296
296
  `codex-only`. Luna has an explicit null model in `claude-only`.
297
- The three `-sol` pair seats for auditor, research-code, and explore-execution
298
- use Sol high in `mixed` and `codex-only`; `claude-only` records intentional
299
- absences. The paired seats run independently on one brief and the driver
300
- reconciles their findings.
297
+ The `-sol` research-code and explore-execution pair seats use Terra high in
298
+ `mixed` and Sol high in `codex-only`; the auditor pair stays Sol high in both.
299
+ Their role IDs remain unchanged. `claude-only` records intentional absences.
300
+ The paired seats run independently on one brief and the driver reconciles
301
+ their findings.
301
302
 
302
303
  The mixed checker uses the Claude Sonnet class at low effort, resolved from
303
304
  saved capabilities. Mixed `axstack-research-x` uses `grok` with
package/docs/workflows.md CHANGED
@@ -140,14 +140,15 @@ and `codex-only`. In `claude-only`, Luna is intentionally absent.
140
140
  The mixed X route uses Grok.
141
141
  The mixed `axstack-auditor` uses Haiku 5.5 xhigh, pinned to `claude-haiku-5-5`;
142
142
  missing that ID or xhigh effort holds without substitution. The mixed
143
- `axstack-explainer-review` uses Sol medium; `axstack-explainer` pins
143
+ `axstack-explainer-review` uses Terra medium; `axstack-explainer` pins
144
144
  `claude-sonnet-5-5` high with class `sonnet`. Both roles cover every Explain
145
145
  visual, including chat Mermaid and anatomy figures.
146
146
  In `claude-only`, `axstack-auditor` uses the Claude Sonnet class at high effort.
147
- The new `-sol` auditor, research-code, and explore-execution seats use Sol high
148
- in `mixed` and `codex-only`; `claude-only` records each as intentionally absent.
149
- Dispatch the base and Sol seats independently on the same brief, then reconcile
150
- their findings per claim without averaging.
147
+ The `-sol` research-code and explore-execution seats use Terra high in `mixed`
148
+ and Sol high in `codex-only`; the auditor pair stays Sol high in both.
149
+ `claude-only` records each as intentionally absent. Their stable role IDs remain
150
+ unchanged. Dispatch the base and paired seats independently on the same brief,
151
+ then reconcile their findings per claim without averaging.
151
152
 
152
153
  The installed `<skills-dir>/axstack/roles.json` adds the selected preset name:
153
154
  `{ "version": 1, "preset": "<name>", "roles": [...] }`. The runtime reads it
@@ -162,7 +163,8 @@ Peer roles use the stable IDs `axstack-reviewer-primary` and
162
163
  `axstack-reviewer-peer`; their provider/class mappings come only from the
163
164
  selected preset and the ordered reviewer table above. The mixed peer coordinator and review-lane binding
164
165
  `axstack-owner` uses Sol high and never reviews. `axstack-reviewer-secondary`
165
- is used in the authored mapping and, alongside `axstack-reviewer-primary`,
166
+ uses Terra xhigh for Sol-authored candidates in `codex-only`. It is used
167
+ in the authored mapping and, alongside `axstack-reviewer-primary`,
166
168
  in codebase findings mode.
167
169
 
168
170
  The unavailable adviser in each single-provider preset stays explicitly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.50.2",
3
+ "version": "0.52.0",
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",
@@ -52,8 +52,8 @@
52
52
  "provider": "codex",
53
53
  "modeId": "full-access",
54
54
  "thinkingOptionId": "xhigh",
55
- "notes": "Eligible authored reviewer for a Sol-authored candidate. Never author or owner; review exact SHA and base across all six angles and acceptance.",
56
- "modelClass": "luna"
55
+ "notes": "Terra xhigh authored reviewer for a Sol-authored candidate. Never author or owner; review exact SHA and base across all six angles and acceptance.",
56
+ "modelClass": "terra"
57
57
  },
58
58
  {
59
59
  "id": "axstack-reviewer-peer",
@@ -102,12 +102,12 @@
102
102
  },
103
103
  {
104
104
  "id": "axstack-research-code-sol",
105
- "name": "Axstack research code Sol",
105
+ "name": "Axstack research code Terra",
106
106
  "provider": "codex",
107
107
  "modeId": "full-access",
108
108
  "thinkingOptionId": "high",
109
- "notes": "Independent Sol high research code investigator: verifies code behavior and executable evidence on the same bounded brief without cross-reading. Reports findings for driver reconciliation.",
110
- "modelClass": "sol"
109
+ "notes": "Independent Terra high research code investigator: verifies code behavior and executable evidence on the same bounded brief without cross-reading. Reports findings for driver reconciliation.",
110
+ "modelClass": "terra"
111
111
  },
112
112
  {
113
113
  "id": "axstack-research-web",
@@ -143,8 +143,8 @@
143
143
  "provider": "codex",
144
144
  "modeId": "full-access",
145
145
  "thinkingOptionId": "medium",
146
- "notes": "Independent Sol medium explanation reviewer: always reviews every visual. Reviews consequential or complex driver-written prose or on request. Checks the exact artifact for text and source fidelity. The browser interaction pass belongs to axstack-ui-verifier. Any artifact change invalidates its review. Resolve the class from saved T3 capabilities at run start; rejection and unavailable settings hold without substitution. Design review follows design.md hard rules and FRONTEND-DESIGN.md (calibration defaults, restraint and quality floor) for every inline HTML page and every Publish article adaptation. For inline pages the reviewer itself renders exact bytes with html_preview at 728px dark and 360px light as a non-publishing self-check. For Publish the reviewer opens axstack-ui-verifier PNGs at 728px dark and 360px light plus 390px mobile and 1440px desktop, covering all four widths in both themes with paths bound to the artifact SHA-256 and recorded before review. Inspect the 1440px desktop screenshot for balanced composition, intentional horizontal margins, bounded prose measure, typography, section spacing and chart legibility. Inspect the 360px and 390px mobile screenshots for gutters, readable text and chart labels. A page failing to look decent is a blocking design finding, repaired by the same author within the two-round visual limit. If only design findings remain after the second round, deliver the visual with each finding named. Never describe a remaining finding as fixed or a clean pass. Apply Delivery's normal holds for other failures and unknown renders. Chat Mermaid and anatomy keep their existing review.",
147
- "modelClass": "sol"
146
+ "notes": "Independent Terra medium explanation reviewer: always reviews every visual. Reviews consequential or complex driver-written prose or on request. Checks the exact artifact for text and source fidelity. The browser interaction pass belongs to axstack-ui-verifier. Any artifact change invalidates its review. Resolve the class from saved T3 capabilities at run start; rejection and unavailable settings hold without substitution. Design review follows design.md hard rules and FRONTEND-DESIGN.md (calibration defaults, restraint and quality floor) for every inline HTML page and every Publish article adaptation. For inline pages the reviewer itself renders exact bytes with html_preview at 728px dark and 360px light as a non-publishing self-check. For Publish the reviewer opens axstack-ui-verifier PNGs at 728px dark and 360px light plus 390px mobile and 1440px desktop, covering all four widths in both themes with paths bound to the artifact SHA-256 and recorded before review. Inspect the 1440px desktop screenshot for balanced composition, intentional horizontal margins, bounded prose measure, typography, section spacing and chart legibility. Inspect the 360px and 390px mobile screenshots for gutters, readable text and chart labels. A page failing to look decent is a blocking design finding, repaired by the same author within the two-round visual limit. If only design findings remain after the second round, deliver the visual with each finding named. Never describe a remaining finding as fixed or a clean pass. Apply Delivery's normal holds for other failures and unknown renders. Chat Mermaid and anatomy keep their existing review.",
147
+ "modelClass": "terra"
148
148
  },
149
149
  {
150
150
  "id": "axstack-ui-verifier",
@@ -185,12 +185,12 @@
185
185
  },
186
186
  {
187
187
  "id": "axstack-explore-execution-sol",
188
- "name": "Axstack execution explorer Sol",
188
+ "name": "Axstack execution explorer Terra",
189
189
  "provider": "codex",
190
190
  "modeId": "full-access",
191
191
  "thinkingOptionId": "high",
192
- "notes": "Independent Sol high execution explorer: runs authorized bounded runtime checks on the same brief without cross-reading. Reports findings for driver reconciliation.",
193
- "modelClass": "sol"
192
+ "notes": "Independent Terra high execution explorer: runs authorized bounded runtime checks on the same brief without cross-reading. Reports findings for driver reconciliation.",
193
+ "modelClass": "terra"
194
194
  },
195
195
  {
196
196
  "id": "axstack-auditor",
@@ -0,0 +1,166 @@
1
+ # Saved New-chat default (opt-in)
2
+
3
+ Run these commands from the installed Axstack skill directory (containing
4
+ `scripts/` and `systemd/`). `bun scripts/sync-default.js --help` lists all inputs
5
+ and writes. Dry-run is the default. `--apply` changes only the global saved
6
+ `defaultModelSelection`; existing chats, dispatch roles and provider settings
7
+ are untouched. Manual New chat may retain a draft model choice; project overrides
8
+ still win. Output lists saved project override IDs. On older T3 settings where
9
+ `projectSettingsFolded` is false/absent, legacy project defaults may also live in
10
+ T3's project records and are not enumerated by this script; inspect Project Settings.
11
+ There is no per-start hook or guarantee that a client has refreshed its saved default.
12
+
13
+ ## Inputs and policy
14
+
15
+ The live path reads the one enabled `cliproxy` source from settings, using its
16
+ URL and management key in memory. When settings contain the literal masked key
17
+ `••••••`, resolve only `secrets/usage-limit-source-<base64url(sourceId)>.bin` beside
18
+ the explicit settings file, using T3's UTF-8/base64url naming. Inline keys still work.
19
+ The settings object stays masked; only a temporary hub config is hydrated. Secret
20
+ files must be owned private regular files, with no symlinks or shared writable
21
+ parent; malformed, missing or unsafe keys hold with a sanitized reason. Neither
22
+ secret bytes nor raw settings/headers/responses are logged or persisted. Like installed T3 0.1.3 `makeCliproxyApi`, it
23
+ GETs `v0/management/auth-files`, then POSTs `api-call` envelopes requesting GET
24
+ Anthropic OAuth `/api/oauth/usage` or Codex `/backend-api/wham/usage`. Hub-side
25
+ `$TOKEN$` substitution keeps account tokens out of the script. It never requests
26
+ account credential contents, reset credits or reset-quota operations. HTTPS is required except
27
+ loopback fixtures, redirects are refused, requests time out after 15 seconds,
28
+ and at most 32 accounts are queried in batches of four. A source/account failure
29
+ holds; reports contain only policy diagnostics, never raw settings or responses.
30
+ No live requests were needed to develop the adapter.
31
+
32
+ The default model catalog comes from existing T3 `caches/<instanceId>.json`
33
+ (sibling to `userdata`), with ready/enabled/installed status, matching driver,
34
+ model slug and effort descriptor. Catalogs older than 24 hours hold; the script
35
+ never refreshes providers. Exactly one enabled instance per target driver is
36
+ required because usage sources do not establish which of several instances
37
+ shares which accounts. Refresh catalogs in T3 if held. These checks establish
38
+ catalog evidence, not successful provider execution.
39
+
40
+ Routes: Claude `claude-opus-5-5` medium; Codex `gpt-6-astra` medium;
41
+ Codex `gpt-6.1-sol` xhigh. The configured instance IDs are discovered, not renamed.
42
+ Both source and account telemetry must be no older than five minutes, with
43
+ valid applicable session/weekly percentages and durations. A supplied reset must
44
+ be a valid future date within its window duration. Missing,
45
+ stale, malformed or ambiguous data holds the entire decision, without failover.
46
+ A successful Codex response containing only a seven-day primary window (secondary
47
+ absent/null) is authoritative single-window telemetry: the adapter explicitly marks
48
+ session n/a. It does not synthesize session capacity. A plain incomplete snapshot
49
+ without this provenance still holds; weekly is always required. An account needs
50
+ remaining quota in every applicable window. Null or absent reset timing is valid for
51
+ Claude session/weekly/scoped Opus and Codex windows. The adapter omits the missing
52
+ reset; it never invents a timestamp. Unknown reset timing alone does **not** mean
53
+ unused or not started: the reported used percentage still governs remaining quota,
54
+ same-account eligibility and Astra's reserve, including exhaustion at 100% used.
55
+ For pressure, an untimed window earns zero elapsed-window credit, a conservative
56
+ lower bound, while its full reported usage is subtracted. `untimedWindows` reports
57
+ these windows (including relevant Opus scope); n/a session windows are separate.
58
+ Malformed non-null dates, stale telemetry and missing applicable windows still hold. A blocked account does not
59
+ exhaust other accounts. Opus scoped weekly limits also constrain eligibility.
60
+
61
+ Installed T3 0.1.3 client `main-lZWM_z1N.js` functions `g7`/`_7` display **equal
62
+ account arithmetic means**, not a weighted quota total: each account contributes
63
+ one percentage. Codex groups by window kind; Claude uses kind plus window ID.
64
+ A weekly-exhausted account contributes 100% session usage. Different resets stay
65
+ per account; T3 lists refill events scaled by member count, rather than inventing
66
+ one pool reset. An n/a session contributes neither a zero nor 100% observation;
67
+ only accounts with an applicable session enter its mean, and an all-n/a session
68
+ is reported as null with status n/a. This script uses that unrounded mean for the reserve check:
69
+ Astra is excluded if either applicable Codex window has less than 25% remaining; exactly
70
+ 25% is eligible. Opus has no Codex reserve exclusion. Percentages do not establish
71
+ absolute tokens or relative subscription sizes. Duplicate identities hold
72
+ instead of silently adding capacity from overlapping sources.
73
+
74
+ Pressure is the worse session/weekly mean of elapsed-window percentage minus
75
+ used percentage, higher meaning more underused capacity. As in T3's pace display,
76
+ a weekly-blocked synthetic session has no duration and is omitted from pace,
77
+ while still contributing 100% to remaining-capacity aggregation. Reset timing is
78
+ computed per account before averaging. Similar pressure is within five percentage
79
+ points; an existing eligible default stays unless a challenger is at least ten
80
+ points better or a fair tie-break is justified.
81
+
82
+ The timer automatically reads the existing `statev2.sqlite` beside settings using
83
+ Bun's built-in SQLite read-only mode. It queries only recent thread metadata from
84
+ `orchestration_v2_projection_threads` across all projects, including archived and
85
+ soft-deleted records still present. It creates no DB, migrates nothing, and issues
86
+ no T3 auth session. Symlink/unsafe DB or WAL/shared-memory paths hold history;
87
+ a WAL without existing shared memory is refused to avoid creating sidecars.
88
+ SQLite's normal transient reader locks are required. Metadata reads are capped at
89
+ 50,000 rows. Missing, incompatible, incomplete or malformed history reports an
90
+ explicit unavailable reason, without pretending the counts are all zero.
91
+
92
+ This route avoids installed CLI limitations: 0.1.0 has no thread-list command;
93
+ 0.1.3's thread list exhausts pagination but issues/revokes an auth session, and
94
+ its project command has no list subcommand. No CLI auth or settings updates are
95
+ needed. `--history-db` selects another existing projection DB; `--history-file`
96
+ overrides the DB with a saved native thread-list array or complete result object.
97
+ A non-null cursor (including zero), hasMore, total mismatch, duplicates or missing
98
+ manual identity makes the inventory unavailable.
99
+
100
+ Count identifiable top-level `createdBy:user` starts from web **or mobile** in the
101
+ preceding seven days, using native `providerInstanceId` and `model` fields (legacy
102
+ modelSelection snapshots also work). Two extra starts can break a pressure tie.
103
+ Projection metadata describes the **current saved model** of a thread created in
104
+ that period, not an immutable record of its original model; hard-deleted/pruned
105
+ records and manual model changes limit fidelity. The source/coverage is reported.
106
+ A supplied project-only list remains caller-scoped, not global history.
107
+
108
+ When history is unavailable, similarly pressured eligible models receive fixed
109
+ six-hour UTC slots in Opus/Astra/Sol order. The target is stable within a slot;
110
+ pressure hysteresis still protects a current default outside a fair tie. This
111
+ prevents indefinite starvation without timer state, a new database, per-tick
112
+ rotation, or any claim of equal chat counts. Quota eligibility/reserves always win.
113
+
114
+ ## Run or enable
115
+
116
+ First inspect a dry-run, including any hold and project overrides:
117
+
118
+ ```bash
119
+ bun scripts/sync-default.js
120
+ bun scripts/sync-default.js --apply
121
+ ```
122
+
123
+ `--usage-file` accepts an array of normalized T3 `UsageLimitSourceSnapshot`
124
+ objects, including per-account `usageLimits`. `--catalog-dir` selects provider
125
+ caches. `--now <ISO>` is fixture-only and requires `--usage-file`.
126
+ Settings symlinks are refused. Apply creates a sibling lock directory and unique
127
+ temporary file, preserves mode, fsyncs, compares current bytes/inode/mode before
128
+ atomic rename, then reads back. A non-cooperating writer still has a small
129
+ compare-to-rename race; this is not a transaction with T3's settings watcher.
130
+ An existing lock holds; after a crash inspect ownership/process state before
131
+ manually removing that exact lock. Dry-run creates no lock or file.
132
+
133
+ The five-minute user timer is a separate explicit opt-in. It runs one bounded
134
+ process, never an agent. It is not enabled by the installer. After accepting its
135
+ writes, install the shipped units:
136
+
137
+ ```bash
138
+ mkdir -p ~/.config/systemd/user
139
+ cp systemd/axstack-default-sync.{service,timer} ~/.config/systemd/user/
140
+ systemctl --user daemon-reload
141
+ systemctl --user enable --now axstack-default-sync.timer
142
+ journalctl --user -u axstack-default-sync.service -n 20
143
+ # Stop future updates:
144
+ systemctl --user disable --now axstack-default-sync.timer
145
+ ```
146
+
147
+ Exit 2 means held; inspect the sanitized reason. Systemd may show that bounded
148
+ run as failed and will try again at the next timer tick. No automatic retry loop,
149
+ provider failover, account routing, or live service upgrade is included.
150
+
151
+ ## Titles
152
+
153
+ On **T3 server >=0.1.3** paste [the short instructions](thread-title-instructions.txt)
154
+ into global Settings → Threads → title instructions, or use the supported
155
+ `server.updateSettings` patch containing only `threadTitleInstructions`.
156
+ Preserve `textGenerationModelSelection`.
157
+ This script never writes title settings; 0.1.0 does not support that field.
158
+ Do not upgrade or restart T3 as part of enabling this script.
159
+
160
+ These are best-effort generation instructions; deterministic child titles come
161
+ from [runtime naming guidance](t3-runtime.md). Supply `Worker: <dispatch-key>`,
162
+ `Reviewer: <dispatch-key>`, `Researcher: <dispatch-key>` or `Subagent: <dispatch-key>`
163
+ explicitly, with a first message. Regeneration and empty-then-human-fed threads
164
+ can invoke generation again; preserve the complete prior title and `Needs you:`.
165
+ Verify New chat selection, settings watcher refresh and title regeneration on the
166
+ actual supported client/server before claiming live behavior.
@@ -15,7 +15,7 @@
15
15
  | --- | --- | --- |
16
16
  | `mixed` | `codex/sol` | `axstack-reviewer-secondary` (`claude/opus` high) |
17
17
  | `mixed` | `claude/opus` | `axstack-reviewer-primary` (`codex/sol` high) |
18
- | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/luna` xhigh) |
18
+ | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/terra` xhigh) |
19
19
  | `claude-only` | `claude/opus` | `axstack-reviewer-secondary` (`claude/sonnet` high) |
20
20
  - `axstack-advisor-astra`/`axstack-advisor-opus` advise and author candidates;
21
21
  `axstack-arena-candidate-grok` adds a family.
@@ -24,7 +24,7 @@
24
24
  `axstack-auditor` audits; `axstack-checker` reports discrepancies.
25
25
  - `axstack-explainer` authors every visual Explain delivers, including pages,
26
26
  chat Mermaid and anatomy figures; mixed pins Sonnet 5.5 high exactly.
27
- - `axstack-explainer-review` always reviews every visual; `codex/sol` medium in mixed.
27
+ - `axstack-explainer-review` always reviews every visual; `codex/terra` medium in mixed.
28
28
  Consequential or complex driver-written prose keeps its review rule; every
29
29
  whiteboard defense is consequential, in chat too.
30
30
  - `axstack-diligence`: read-only [diligence checks](diligence.md) for every authored PR
@@ -45,9 +45,10 @@
45
45
  `axstack-explore-codebase-luna` per facet in mixed (Haiku high + Luna
46
46
  xhigh) and codex-only (Sol high + Luna xhigh). Claude-only requires
47
47
  the Haiku high base seat per facet.
48
- - Sol pairs `axstack-auditor-sol`/`axstack-research-code-sol`/
48
+ - Sol pairs (stable `-sol` IDs) `axstack-auditor-sol`/`axstack-research-code-sol`/
49
49
  `axstack-explore-execution-sol`: `codex/sol` high in
50
- mixed/codex-only; intentionally absent in claude-only. Dispatch each
50
+ mixed/codex-only; intentionally absent in claude-only. In mixed,
51
+ research-code and explore-execution use `codex/terra` high instead. Dispatch each
51
52
  independently from its base seat on the same bounded brief without
52
53
  cross-reading. The driver reconciles findings per claim, never averages.
53
54
  Record intentional absence and continue with Sonnet alone.
@@ -75,9 +75,9 @@ configuration; unrelated configurations remain eligible.
75
75
 
76
76
  | Roles | Mechanism and permitted workspace | Completion |
77
77
  |---|---|---|
78
- | Advisers, research, read-only explorers, explainers, diligence, checker, auditor, arena prose candidates and judges, escalation | Must use async `delegate_task` in the driver worktree, title = dispatch key; tracked and untracked files stay untouched; writes only `<run>/evidence/<key>/` | Native notification followed by persisted `task_status` |
79
- | Reviewers (peer/authored), release checks, debug investigators, execution investigators (`axstack-explore-execution`), UI verifier candidate checks (`axstack-ui-verifier`) | Must use async `delegate_task`, title = dispatch key; driver makes a disposable detached checkout of candidate SHA and pinned base with `git worktree add --detach <run>/checkouts/<key> <sha>` (plus pinned debug patch); brief requires `cd` into it; only disposable probes write there, outputs go to `<run>/evidence/<key>/` | Same delegated terminal checks |
80
- | Author and repairs | Fresh authors must use async `delegate_task` with `isolation:"worktree"`, title = dispatch key, stable `clientRequestId` in their own worktree, kept until PR merges or closes; repairs use `t3_thread_send` to the same child | Delegated terminal checks plus verified candidate |
78
+ | Advisers, research, read-only explorers, explainers, diligence, checker, auditor, arena prose candidates and judges, escalation | Must use async `delegate_task` in the driver worktree, title = role-labelled dispatch key; tracked and untracked files stay untouched; writes only `<run>/evidence/<key>/` | Native notification followed by persisted `task_status` |
79
+ | Reviewers (peer/authored), release checks, debug investigators, execution investigators (`axstack-explore-execution`), UI verifier candidate checks (`axstack-ui-verifier`) | Must use async `delegate_task`, title = role-labelled dispatch key; driver makes a disposable detached checkout of candidate SHA and pinned base with `git worktree add --detach <run>/checkouts/<key> <sha>` (plus pinned debug patch); brief requires `cd` into it; only disposable probes write there, outputs go to `<run>/evidence/<key>/` | Same delegated terminal checks |
80
+ | Author and repairs | Fresh authors must use async `delegate_task` with `isolation:"worktree"`, title = role-labelled dispatch key, stable `clientRequestId` in their own worktree, kept until PR merges or closes; repairs use `t3_thread_send` to the same child | Delegated terminal checks plus verified candidate |
81
81
  | Owner | Driver thread in Driver worktree; never writes tracked candidate source or tests; planning artifacts allowed only for repository Markdown; scope, integration, forge mutations and record | No worker launch |
82
82
 
83
83
  The UI verifier checkout row applies to candidate checks, while a page without
@@ -89,15 +89,21 @@ The driver may write planning artifacts (spec, ticket map) in its own worktree w
89
89
  Repairs return to that author. Missing or idle sessions grant no ownership transfer.
90
90
  The current chat/driver has no role row in any preset.
91
91
 
92
- At launch and rename, visible driver thread titles express a plain outcome of
93
- at most 30 characters.
92
+ At launch and rename, visible driver thread titles express a plain outcome with
93
+ `Driver:` prefix, at most 30 characters including the prefix.
94
94
  Visible driver thread titles use no repo, issue or size prefix.
95
- Hidden children keep dispatch-key titles.
95
+ Hidden children keep role-labelled dispatch-key titles.
96
96
  D9 applies to driver threads only, while flagged unbound pass threads retain the
97
97
  `Needs you: <kind> <repo>#<n>` (or `<lane>`) rename under Automations.
98
98
 
99
99
  The dispatch key must be `<run>:<role>:<task>:a<n>`, recorded before launch and
100
- used as the exact whole T3 title. Substring matches do not establish identity.
100
+ preserved byte-for-byte after its role prefix in the exact whole T3 title.
101
+ Use `Worker: <dispatch-key>` for authors, `Reviewer: <dispatch-key>` for reviewers,
102
+ `Researcher: <dispatch-key>` for research, and `Subagent: <dispatch-key>` for other children.
103
+ Record both the key and complete display title; compare the exact complete title
104
+ during recovery. Substring matches do not establish identity. Branch and task
105
+ identity continue to use the original dispatch key. On title regeneration preserve
106
+ explicit role-labelled dispatch keys and `Needs you:` notices unchanged.
101
107
  Each dispatch binds the approved spec or small-change intent, brief, authority, role snapshot, base and candidate to its dispatch key.
102
108
  Every dispatch brief must state that dispatched roles never call `html_render`.
103
109
  Dispatched roles may call `html_preview` within [UI verification](ui-verification.md).
@@ -0,0 +1,3 @@
1
+ Return only the title. For a new ordinary manual chat, use "Driver: " followed by a short sentence-case outcome naming the actual request. Aim for 30 characters including the prefix. No quotes, emoji, final punctuation, repository, issue or size prefix. Do not invent scope or turn a question into a repair task.
2
+ Use an explicit delegated role from the supplied context only: Worker:, Reviewer:, Researcher:, or Subagent:. Merely discussing a role does not assign it.
3
+ On regeneration preserve an existing role prefix and every explicit dispatch/tracking key byte-for-byte. Return a complete explicit role-labelled title containing a dispatch key unchanged, regardless of length. Preserve Needs you: notices and identifying details unchanged.
@@ -0,0 +1,134 @@
1
+ import { historyCounts } from './driver-history.js';
2
+ const routes = [
3
+ ['claudeAgent', 'claude-opus-5-5', 'effort', 'medium'],
4
+ ['codex', 'gpt-6-astra', 'reasoningEffort', 'medium'],
5
+ ['codex', 'gpt-6.1-sol', 'reasoningEffort', 'xhigh'],
6
+ ];
7
+ const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
8
+ const fresh = (date, now, age) => Number.isFinite(Date.parse(date)) && now >= Date.parse(date) && now - Date.parse(date) <= age;
9
+ const same = (a, b) => a?.instanceId === b?.instanceId && a?.model === b?.model;
10
+
11
+ // Unknown reset timing earns no elapsed-window allowance; reported usage remains authoritative.
12
+ function elapsed(window, now, minutes) {
13
+ if (window.resetsAt == null) return 0;
14
+ const reset = typeof window.resetsAt === 'string' ? Date.parse(window.resetsAt) : NaN;
15
+ if (!Number.isFinite(reset) || reset <= now || reset - now > minutes * 60000) throw Error('malformed reset timing');
16
+ return 1 - (reset - now) / (minutes * 60000);
17
+ }
18
+
19
+ function pool(accounts, driver, now) {
20
+ const members = accounts.filter((a) => a.driver === driver);
21
+ if (!members.length) throw Error(`missing ${driver} accounts`);
22
+ const rows = members.map((a) => {
23
+ const u = a.usageLimits;
24
+ if (u?.unavailable || !fresh(u?.checkedAt, now, 300000) || !Array.isArray(u?.windows)) throw Error(`unknown ${driver} telemetry`);
25
+ const windows = ['session', 'weekly'].map((kind) => {
26
+ const found = u.windows.filter((w) => w.kind === kind && !(driver === 'claudeAgent' && w.id?.startsWith('seven_day_')));
27
+ if (!found.length && driver === 'codex' && kind === 'session'
28
+ && u.notApplicable?.session === 'verified Codex weekly-only response') return null;
29
+ if (found.length !== 1) throw Error(`missing or ambiguous ${driver} ${kind} window`);
30
+ const w = found[0];
31
+ if (!Number.isFinite(w.usedPercent) || w.usedPercent < 0 || w.usedPercent > 100
32
+ || !Number.isFinite(w.windowDurationMins) || w.windowDurationMins <= 0) throw Error(`malformed ${driver} ${kind} window`);
33
+ return { used: w.usedPercent, elapsed: elapsed(w, now, w.windowDurationMins), resetKnown: w.resetsAt != null };
34
+ });
35
+ const scoped = u.windows.filter((w) => w.id?.startsWith('seven_day_') && /opus/i.test(w.label ?? ''));
36
+ for (const w of scoped) {
37
+ if (!Number.isFinite(w.usedPercent) || w.usedPercent < 0 || w.usedPercent > 100) throw Error('unknown Opus scoped telemetry');
38
+ elapsed(w, now, 10080);
39
+ }
40
+ const untimedWindows = windows.filter((w) => w?.resetKnown === false).length
41
+ + scoped.filter((w) => w.resetsAt == null).length;
42
+ const viable = windows.every((w) => w === null || w.used < 100) && scoped.every((w) => w.usedPercent < 100);
43
+ // Match T3's pool display: a weekly-blocked account contributes 100% session used.
44
+ if (windows[0] && windows[1].used >= 100) windows[0] = { used: 100, elapsed: null };
45
+ return { windows, viable, untimedWindows };
46
+ });
47
+ const applicable = [0, 1].map((i) => rows.flatMap((r) => r.windows[i] ? [r.windows[i]] : []));
48
+ const remaining = applicable.map((ws) => ws.length ? 100 - mean(ws.map((w) => w.used)) : null);
49
+ const pace = applicable.flatMap((ws) => {
50
+ const timed = ws.filter((w) => w.elapsed !== null);
51
+ return timed.length ? [mean(timed.map((w) => w.elapsed * 100 - w.used))] : [];
52
+ });
53
+ return { sessionRemaining: remaining[0], weeklyRemaining: remaining[1],
54
+ sessionStatus: applicable[0].length ? 'applicable' : 'n/a', sessionAccounts: applicable[0].length,
55
+ eligibleAccounts: rows.filter((r) => r.viable).length,
56
+ untimedWindows: rows.reduce((sum, r) => sum + r.untimedWindows, 0), score: Math.min(...pace) };
57
+ }
58
+
59
+ function candidates(settings, catalogs, now) {
60
+ return routes.map(([driver, model, option, effort]) => {
61
+ const instances = Object.entries(settings.providerInstances ?? {}).filter(([, p]) => p.enabled === true && p.driver === driver);
62
+ if (instances.length !== 1) throw Error(`missing or ambiguous enabled ${driver} instance`);
63
+ const instanceId = instances[0][0];
64
+ const found = catalogs.filter((c) => c.instanceId === instanceId);
65
+ const c = found[0];
66
+ const m = c?.models?.find((m) => m.slug === model);
67
+ if (found.length !== 1 || c.driver !== driver || c.enabled !== true || c.installed !== true || c.status !== 'ready'
68
+ || !fresh(c.checkedAt, now, 86400000)
69
+ || !m?.capabilities?.optionDescriptors?.some((o) => o.id === option && o.options?.some((v) => v.id === effort))) {
70
+ throw Error(`unverified ${driver} model catalog`);
71
+ }
72
+ return { driver, selection: { instanceId, model, options: [{ id: option, value: effort }] } };
73
+ });
74
+ }
75
+
76
+ export function decide(settings, snapshots, catalogs, now, history = null) {
77
+ const result = { action: 'held', selection: settings.defaultModelSelection,
78
+ projectOverrides: Object.entries(settings.projectSettingsOverrides ?? {})
79
+ .filter(([, p]) => p?.defaultModelSelection !== undefined).map(([id]) => id),
80
+ projectOverrideCoverage: settings.projectSettingsFolded === true ? 'saved settings overrides'
81
+ : 'saved settings overrides only; legacy project records not inspected',
82
+ fairness: 'history unavailable; six-hour UTC tie rotation (not equal chat counts)' };
83
+ try {
84
+ if (!Number.isFinite(now) || !Array.isArray(snapshots) || !snapshots.length || !Array.isArray(catalogs)) throw Error('missing input');
85
+ const all = [];
86
+ const seen = new Set();
87
+ for (const s of snapshots) {
88
+ if (s.error === 'hub management key missing or unsafe') throw Error('hub management key missing or unsafe');
89
+ if (s.kind !== 'cliproxy' || s.error || !fresh(s.checkedAt, now, 300000) || !Array.isArray(s.accounts)) throw Error('unknown source telemetry');
90
+ for (const a of s.accounts) {
91
+ if (!['codex', 'claudeAgent'].includes(a.driver)) continue;
92
+ // Multiple sources may expose one account. Hold rather than double-count capacity.
93
+ const key = `${a.driver}:${a.workspaceId ?? a.email ?? a.id}`;
94
+ if (!a.id || seen.has(key)) throw Error('ambiguous duplicate account');
95
+ seen.add(key); all.push(a);
96
+ }
97
+ }
98
+ const choices = candidates(settings, catalogs, now);
99
+ result.pools = Object.fromEntries(['claudeAgent', 'codex'].map((d) => [d, pool(all, d, now)]));
100
+ const eligible = choices.filter((c) => result.pools[c.driver].eligibleAccounts > 0
101
+ && (c.selection.model !== 'gpt-6-astra'
102
+ || Math.min(...[result.pools.codex.sessionRemaining, result.pools.codex.weeklyRemaining].filter((v) => v !== null)) >= 25));
103
+ if (!eligible.length) throw Error('both pools exhausted; no account has both windows available');
104
+ const best = Math.max(...eligible.map((c) => result.pools[c.driver].score));
105
+ const tied = eligible.filter((c) => best - result.pools[c.driver].score <= 5);
106
+ const current = eligible.find((c) => same(c.selection, result.selection));
107
+ result.history = historyCounts(history, choices, now);
108
+ const counts = result.history.status === 'available' ? result.history.counts : null;
109
+ let chosen;
110
+ if (counts) {
111
+ result.fairness = 'seven-day manual driver starts';
112
+ const count = (c) => counts[c.selection.model];
113
+ const least = Math.min(...tied.map(count));
114
+ // A difference of one is normal; avoid shifting on every timer tick.
115
+ if (!current || best - result.pools[current.driver].score >= 10 || count(current) - least >= 2) {
116
+ chosen = tied.find((c) => count(c) === least);
117
+ }
118
+ }
119
+ if (!chosen && current && best - result.pools[current.driver].score < 10) {
120
+ // Fixed UTC slots prevent starvation without persistent timer state or per-tick rotation.
121
+ const rotation = Math.floor(now / (6 * 3600000)) % choices.length;
122
+ const target = [...choices.slice(rotation), ...choices.slice(0, rotation)].find((c) => tied.includes(c));
123
+ chosen = !counts && tied.includes(current) ? target : current;
124
+ }
125
+ if (!chosen) {
126
+ const index = choices.findIndex((c) => same(c.selection, result.selection));
127
+ chosen = [...choices.slice(index + 1), ...choices.slice(0, index + 1)].find((c) => tied.includes(c));
128
+ }
129
+ const equal = JSON.stringify(chosen.selection) === JSON.stringify(result.selection);
130
+ return { ...result, action: equal ? 'unchanged' : 'update', reason: equal ? 'stable default' : 'capacity or fair tie-break', selection: chosen.selection };
131
+ } catch (error) {
132
+ return { ...result, reason: error.message };
133
+ }
134
+ }
@@ -0,0 +1,36 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ // Cooperating writers share this lock; non-cooperating T3 writes are compared immediately before rename.
4
+ export function commit(path, original, selection) {
5
+ const lock = `${path}.axstack-default-sync.lock`;
6
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
7
+ catch (error) { if (error.code === 'EEXIST') return 'locked'; throw error; }
8
+ let temp;
9
+ let fd;
10
+ try {
11
+ const info = fs.lstatSync(path);
12
+ if (!info.isFile() || info.isSymbolicLink()) throw Error('settings must be a regular file; symlinks are refused');
13
+ if (fs.readFileSync(path, 'utf8') !== original) return 'concurrent-edit';
14
+ const settings = JSON.parse(original);
15
+ settings.defaultModelSelection = selection;
16
+ const next = `${JSON.stringify(settings, null, 2)}\n`;
17
+ if (JSON.stringify(JSON.parse(original).defaultModelSelection) === JSON.stringify(selection)) return 'unchanged';
18
+ temp = `${path}.axstack-default-sync-${crypto.randomUUID()}.tmp`;
19
+ fd = fs.openSync(temp, 'wx', info.mode & 0o7777);
20
+ fs.fchmodSync(fd, info.mode & 0o7777);
21
+ fs.writeFileSync(fd, next);
22
+ fs.fsyncSync(fd);
23
+ fs.closeSync(fd); fd = undefined;
24
+ const latest = fs.lstatSync(path);
25
+ if (latest.ino !== info.ino || latest.dev !== info.dev || latest.mode !== info.mode
26
+ || fs.readFileSync(path, 'utf8') !== original) return 'concurrent-edit';
27
+ // A non-cooperating writer still has a small compare-to-rename race; no file API provides CAS.
28
+ fs.renameSync(temp, path); temp = undefined;
29
+ if (fs.readFileSync(path, 'utf8') !== next) throw Error('settings read-back mismatch');
30
+ return 'updated';
31
+ } finally {
32
+ if (fd !== undefined) fs.closeSync(fd);
33
+ if (temp) fs.unlinkSync(temp);
34
+ fs.rmdirSync(lock);
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import * as fs from 'node:fs';
3
+ const week = 7 * 86400000;
4
+
5
+ // Read existing projections across all projects; never create a DB, migrate, or use auth sessions.
6
+ export function readHistory(settingsPath, now, explicitPath) {
7
+ const path = explicitPath ?? `${settingsPath.slice(0, settingsPath.lastIndexOf('/'))}/statev2.sqlite`;
8
+ let db;
9
+ try {
10
+ const info = fs.lstatSync(path);
11
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o022)) throw Error();
12
+ for (const suffix of ['-wal', '-shm']) {
13
+ try {
14
+ const s = fs.lstatSync(`${path}${suffix}`);
15
+ if (!s.isFile() || s.isSymbolicLink() || s.uid !== info.uid || (s.mode & 0o022)) throw Error();
16
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
17
+ }
18
+ if (fs.existsSync(`${path}-wal`) && !fs.existsSync(`${path}-shm`)) {
19
+ return { unavailable: 'T3 history WAL has no existing shared memory; refusing sidecar creation' };
20
+ }
21
+ db = new Database(path, { readonly: true });
22
+ const threads = db.query(`SELECT thread_id AS threadId, created_at AS createdAt,
23
+ provider_instance_id AS providerInstanceId,
24
+ json_extract(payload_json, '$.createdBy') AS createdBy,
25
+ json_extract(payload_json, '$.creationSource') AS creationSource,
26
+ json_extract(payload_json, '$.modelSelection.model') AS model,
27
+ json_extract(payload_json, '$.lineage.parentThreadId') AS parentThreadId,
28
+ json_type(payload_json, '$.lineage.parentThreadId') AS parentType
29
+ FROM orchestration_v2_projection_threads WHERE created_at >= ? AND created_at <= ?
30
+ ORDER BY created_at, thread_id LIMIT 50001`).all(new Date(now - week).toISOString(), new Date(now).toISOString());
31
+ if (threads.length > 50000) return { unavailable: 'T3 history exceeds bounded inventory limit' };
32
+ if (threads.some((t) => t.createdBy === 'user' && ['web', 'mobile'].includes(t.creationSource)
33
+ && !['null', 'text'].includes(t.parentType))) return { unavailable: 'T3 history lacks parent identity' };
34
+ return { threads, complete: true, total: threads.length,
35
+ source: 'T3 projection database: all projects, current saved models of threads created in seven days' };
36
+ } catch {
37
+ return { unavailable: 'T3 history database unavailable, unsafe or incompatible' };
38
+ } finally { db?.close(); }
39
+ }
40
+
41
+ export function historyCounts(history, choices, now) {
42
+ const unknown = (reason) => ({ status: 'unavailable', reason });
43
+ if (history?.unavailable) {
44
+ const reasons = ['T3 history database unavailable, unsafe or incompatible', 'T3 history exceeds bounded inventory limit',
45
+ 'T3 history lacks parent identity', 'T3 history WAL has no existing shared memory; refusing sidecar creation',
46
+ 'history file unavailable or malformed'];
47
+ return unknown(reasons.includes(history.unavailable) ? history.unavailable : 'T3 history unavailable; check database or supplied snapshot');
48
+ }
49
+ const threads = Array.isArray(history) ? history : history?.threads;
50
+ if (!Array.isArray(threads)) return unknown('no complete T3 history inventory');
51
+ if (history.hasMore === true || history.nextCursor != null
52
+ || (history.total !== undefined && history.total !== threads.length)) return unknown('incomplete or paginated T3 history');
53
+ const counts = Object.fromEntries(choices.map((c) => [c.selection.model, 0]));
54
+ const seen = new Set();
55
+ for (const t of threads) {
56
+ const id = t.threadId ?? t.id;
57
+ const at = Date.parse(t.createdAt);
58
+ if (typeof id !== 'string' || !id || seen.has(id) || !Number.isFinite(at)
59
+ || typeof t.createdBy !== 'string' || typeof t.creationSource !== 'string') return unknown('malformed T3 history record');
60
+ seen.add(id);
61
+ if (t.createdBy !== 'user' || !['web', 'mobile'].includes(t.creationSource) || at > now || now - at > week) continue;
62
+ if (!Object.hasOwn(t, 'parentThreadId')) return unknown('manual history lacks parent identity');
63
+ if (t.parentThreadId !== null) continue;
64
+ const instance = t.providerInstanceId ?? t.modelSelection?.instanceId;
65
+ const model = t.model ?? t.modelSelection?.model;
66
+ if (typeof instance !== 'string' || typeof model !== 'string') return unknown('manual history lacks provider/model identity');
67
+ if (choices.some((c) => c.selection.instanceId === instance && c.selection.model === model)) counts[model]++;
68
+ }
69
+ return { status: 'available', counts, source: history.source ?? 'saved thread list; caller-supplied scope' };
70
+ }
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ // Resolve only T3's masked usage-source field; never hydrate or persist the settings object.
4
+ export function managementKey(value, sourceId, settingsPath) {
5
+ try {
6
+ if (value === '••••••') {
7
+ if (typeof settingsPath !== 'string' || !settingsPath.includes('/') || typeof sourceId !== 'string'
8
+ || !sourceId || sourceId.length > 512 || Buffer.from(sourceId, 'utf8').toString('utf8') !== sourceId) throw Error();
9
+ const directory = `${settingsPath.slice(0, settingsPath.lastIndexOf('/'))}/secrets`;
10
+ const parent = fs.lstatSync(directory);
11
+ if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.() || (parent.mode & 0o022)) throw Error();
12
+ const path = `${directory}/usage-limit-source-${Buffer.from(sourceId, 'utf8').toString('base64url')}.bin`;
13
+ const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
14
+ try {
15
+ const info = fs.fstatSync(fd);
16
+ if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o077) || info.nlink !== 1
17
+ || info.size < 1 || info.size > 4096) throw Error();
18
+ value = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(fd));
19
+ } finally { fs.closeSync(fd); }
20
+ }
21
+ // HTTP bearer keys must be bounded, unmasked printable ASCII without whitespace/control bytes.
22
+ if (typeof value !== 'string' || !/^[!-~]{1,4096}$/.test(value)) throw Error();
23
+ return value;
24
+ } catch {
25
+ throw Error('hub management key missing or unsafe');
26
+ }
27
+ }
@@ -0,0 +1,93 @@
1
+ import { managementKey } from './hub-secret.js';
2
+ // Read-only subset of T3 0.1.3 makeCliproxyApi. Never fetch token files or reset credits.
3
+ async function management(config, path, body) {
4
+ const base = new URL(config.url.endsWith('/') ? config.url : `${config.url}/`);
5
+ if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))) {
6
+ throw Error('hub requires HTTPS or loopback HTTP');
7
+ }
8
+ if (base.username || base.password || !config.managementKey) throw Error('invalid hub configuration');
9
+ const response = await fetch(new URL(`v0/management/${path}`, base), {
10
+ method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
11
+ headers: { Authorization: `Bearer ${config.managementKey}`, 'Content-Type': 'application/json' },
12
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
13
+ });
14
+ if (!response.ok) throw Error('hub management request refused');
15
+ return response.json();
16
+ }
17
+
18
+ async function accountUsage(config, account, now) {
19
+ const checkedAt = new Date(now).toISOString();
20
+ const driver = account.provider === 'claude' ? 'claudeAgent' : 'codex';
21
+ const base = { id: account.id, driver,
22
+ ...(account.email ? { email: account.email } : {}),
23
+ ...(account.id_token?.chatgpt_account_id ? { workspaceId: account.id_token.chatgpt_account_id } : {}) };
24
+ try {
25
+ if (typeof account.auth_index !== 'string' || !account.auth_index) throw Error('invalid auth index');
26
+ const header = driver === 'codex' ? { Authorization: 'Bearer $TOKEN$', 'Content-Type': 'application/json',
27
+ 'OpenAI-Beta': 'codex-1', Originator: 'Codex Desktop',
28
+ ...(base.workspaceId ? { 'Chatgpt-Account-Id': base.workspaceId } : {}) }
29
+ : { Authorization: 'Bearer $TOKEN$', 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-code/2.1.273' };
30
+ const response = await management(config, 'api-call', { auth_index: account.auth_index, method: 'GET',
31
+ url: driver === 'codex' ? 'https://chatgpt.com/backend-api/wham/usage' : 'https://api.anthropic.com/api/oauth/usage', header });
32
+ if (!Number.isInteger(response.status_code) || response.status_code < 200 || response.status_code >= 300 || typeof response.body !== 'string') throw Error('provider refused');
33
+ const usage = JSON.parse(response.body);
34
+ if (!usage || typeof usage !== 'object' || usage.error || usage.errors) throw Error('provider payload error');
35
+ const windows = [];
36
+ if (driver === 'codex') {
37
+ for (const key of ['primary_window', 'secondary_window']) {
38
+ const w = usage.rate_limit?.[key];
39
+ if (w?.reset_at != null && (typeof w.reset_at !== 'number' || !Number.isFinite(w.reset_at))) throw Error('invalid Codex reset');
40
+ const minutes = w?.limit_window_seconds / 60;
41
+ const kind = minutes >= 43200 ? 'monthly' : minutes >= 10080 ? 'weekly' : 'session';
42
+ if (w) windows.push({ id: key === 'primary_window' ? 'primary' : 'secondary', kind, label: kind, usedPercent: w.used_percent,
43
+ ...(typeof w.reset_at === 'number' ? { resetsAt: new Date(w.reset_at * 1000).toISOString() } : {}),
44
+ windowDurationMins: w.limit_window_seconds / 60 });
45
+ }
46
+ } else {
47
+ for (const [key, kind, duration] of [['five_hour', 'session', 300], ['seven_day', 'weekly', 10080]]) {
48
+ const w = usage[key];
49
+ if (w) windows.push({ id: key, kind, label: key, usedPercent: w.utilization,
50
+ ...(w.resets_at == null ? {} : { resetsAt: w.resets_at }), windowDurationMins: duration });
51
+ }
52
+ for (const w of usage.limits ?? []) {
53
+ if (w.kind === 'weekly_scoped') windows.push({ id: `seven_day_${String(w.scope?.model?.display_name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, kind: 'weekly',
54
+ label: `Weekly · ${w.scope?.model?.display_name ?? ''}`, usedPercent: w.percent, ...(w.resets_at == null ? {} : { resetsAt: w.resets_at }), windowDurationMins: 10080 });
55
+ }
56
+ }
57
+ const weeklyOnly = driver === 'codex' && usage.rate_limit?.primary_window?.limit_window_seconds === 604800
58
+ && usage.rate_limit.secondary_window == null && windows.length === 1 && windows[0].kind === 'weekly';
59
+ return { ...base, usageLimits: { checkedAt, windows,
60
+ ...(weeklyOnly ? { notApplicable: { session: 'verified Codex weekly-only response' } } : {}) } };
61
+ } catch {
62
+ return { ...base, usageLimits: { checkedAt, windows: [], unavailable: { reason: 'probeFailed' } } };
63
+ }
64
+ }
65
+
66
+ export async function readHub(settings, now = Date.now(), settingsPath) {
67
+ const sources = Object.entries(settings.usageLimitSources ?? {}).filter(([, s]) => s.enabled === true && s.kind === 'cliproxy');
68
+ if (sources.length !== 1) throw Error('expected exactly one enabled cliproxy usage source');
69
+ const snapshots = [];
70
+ for (const [id, source] of sources) {
71
+ let config;
72
+ try { config = { ...source, managementKey: managementKey(source.managementKey, id, settingsPath) }; }
73
+ catch {
74
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts: [], error: 'hub management key missing or unsafe' });
75
+ continue;
76
+ }
77
+ try {
78
+ const raw = await management(config, 'auth-files');
79
+ if (!Array.isArray(raw.files)) throw Error('invalid account list');
80
+ const files = raw.files.filter((a) => !a.disabled && ['claude', 'codex'].includes(a.provider));
81
+ // Bound work and duration; a large hub needs a separately accepted batching policy.
82
+ if (files.length > 32) throw Error('hub account limit exceeded');
83
+ const accounts = [];
84
+ for (let i = 0; i < files.length; i += 4) {
85
+ accounts.push(...await Promise.all(files.slice(i, i + 4).map((a) => accountUsage(config, a, now))));
86
+ }
87
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts });
88
+ } catch {
89
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts: [], error: 'hub usage unavailable' });
90
+ }
91
+ }
92
+ return snapshots;
93
+ }
@@ -0,0 +1,76 @@
1
+ import * as fs from 'node:fs';
2
+ import { decide } from './default-policy.js';
3
+ import { commit } from './default-settings.js';
4
+ import { readHistory } from './driver-history.js';
5
+ import { readHub } from './hub-usage.js';
6
+ export { decide } from './default-policy.js';
7
+
8
+ const help = `Usage: bun sync-default.js [--settings path] [--dry-run | --apply]
9
+ --usage-file path T3 UsageLimitSourceSnapshot array (otherwise read configured hub)
10
+ --catalog-dir path T3 provider cache directory (default: T3 caches beside userdata)
11
+ --history-file path Complete saved native T3 thread-list JSON (overrides database read)
12
+ --history-db path Existing T3 projection DB (default: statev2.sqlite beside settings)
13
+ --now ISO Deterministic clock for fixtures; cannot combine with live hub
14
+ --help Print this help
15
+ Dry-run is the default and writes nothing. Reads settings, provider model caches,
16
+ existing T3 projection history read-only (no create/migrate/auth updates), optional snapshots,
17
+ or configured cliproxy auth-files/api-call usage endpoints.
18
+ Masked hub keys read ONLY userdata/secrets/usage-limit-source-<base64url(sourceId)>.bin
19
+ beside --settings, in memory. Secret symlinks, unsafe permissions and malformed keys hold.
20
+ --apply writes ONLY global defaultModelSelection in --settings (default ~/.t3/userdata/settings.json).
21
+ Apply creates a sibling .axstack-default-sync.lock directory and unique .tmp file,
22
+ fsyncs and atomically renames settings, preserves mode, compares concurrent edits
23
+ and reads back; removes its lock/temp. Existing locks hold (no stale-lock stealing).
24
+ Symlinks refused. Other settings, project overrides, threads, roles and credentials
25
+ are never modified. Does not install/enable timers or write title instructions.
26
+ Exit: 0 decision/unchanged, 1 input/I/O error, 2 held. Output contains no secrets.`;
27
+
28
+ async function main() {
29
+ const options = {};
30
+ const values = ['--settings', '--usage-file', '--catalog-dir', '--history-file', '--history-db', '--now'];
31
+ const args = process.argv.slice(2);
32
+ for (let i = 0; i < args.length; i++) {
33
+ const flag = args[i];
34
+ if (flag === '--help') { console.log(help); return; }
35
+ if (Object.hasOwn(options, flag)) throw Error('duplicate flag');
36
+ if (['--apply', '--dry-run'].includes(flag)) options[flag] = true;
37
+ else if (values.includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) options[flag] = args[++i];
38
+ else throw Error('invalid arguments');
39
+ }
40
+ if (options['--apply'] && options['--dry-run']) throw Error('conflicting modes');
41
+ if (options['--now'] && !options['--usage-file']) throw Error('--now requires --usage-file');
42
+ const requested = options['--settings'] ?? `${process.env.HOME}/.t3/userdata/settings.json`;
43
+ const stat = fs.lstatSync(requested);
44
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Error('settings must be a regular file');
45
+ const path = fs.realpathSync(requested);
46
+ const original = fs.readFileSync(path, 'utf8');
47
+ const settings = JSON.parse(original);
48
+ const now = options['--now'] ? Date.parse(options['--now']) : Date.now();
49
+ const cache = options['--catalog-dir'] ?? `${path.slice(0, path.lastIndexOf('/'))}/../caches`;
50
+ const catalogs = [];
51
+ for (const [id, p] of Object.entries(settings.providerInstances ?? {})) {
52
+ if (p.enabled !== true || !['claudeAgent', 'codex'].includes(p.driver)) continue;
53
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw Error('invalid instance ID');
54
+ try { catalogs.push(JSON.parse(fs.readFileSync(`${cache}/${id}.json`, 'utf8'))); }
55
+ catch { /* Policy reports an unknown catalog; never guess a model. */ }
56
+ }
57
+ const usage = options['--usage-file'] ? JSON.parse(fs.readFileSync(options['--usage-file'], 'utf8')) : await readHub(settings, now, path);
58
+ let history;
59
+ if (options['--history-file']) {
60
+ try { history = JSON.parse(fs.readFileSync(options['--history-file'], 'utf8')); }
61
+ catch { history = { unavailable: 'history file unavailable or malformed' }; }
62
+ } else history = readHistory(path, now, options['--history-db']);
63
+ const result = decide(settings, usage, catalogs, now, history);
64
+ if (result.action === 'update' && options['--apply']) {
65
+ const status = commit(path, original, result.selection);
66
+ result.action = status === 'updated' ? 'updated' : 'held';
67
+ if (status !== 'updated') result.reason = status;
68
+ }
69
+ result.mode = options['--apply'] ? 'apply' : 'dry-run';
70
+ console.log(JSON.stringify(result));
71
+ if (result.action === 'held') process.exitCode = 2;
72
+ }
73
+ if (import.meta.main) main().catch(() => {
74
+ console.error('default sync failed: invalid input or settings I/O; no sensitive details logged');
75
+ process.exitCode = 1;
76
+ });
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=Choose the saved T3 New-chat default from subscription capacity
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Environment=PATH=%h/.bun/bin:/usr/local/bin:/usr/bin:/bin
7
+ UMask=0077
8
+ TimeoutStartSec=180
9
+ ExecStart=/usr/bin/env bun %h/.agents/skills/axstack/scripts/sync-default.js --apply
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Refresh the saved T3 New-chat default every five minutes (opt-in)
3
+
4
+ [Timer]
5
+ OnStartupSec=5min
6
+ OnUnitActiveSec=5min
7
+ Unit=axstack-default-sync.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -109,7 +109,7 @@ counts with denominators plus the evidence behind the count:
109
109
  | --- | --- | --- |
110
110
  | `mixed` | `codex/sol` | `axstack-reviewer-secondary` (`claude/opus` high) |
111
111
  | `mixed` | `claude/opus` | `axstack-reviewer-primary` (`codex/sol` high) |
112
- | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/luna` xhigh) |
112
+ | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/terra` xhigh) |
113
113
  | `claude-only` | `claude/opus` | `axstack-reviewer-secondary` (`claude/sonnet` high) |
114
114
  - Simplification applicability determinations evidenced / total candidate diffs,
115
115
  and complete simplification receipts / total candidates, broken down as
@@ -65,7 +65,7 @@ Immediately before an actual role dispatch, read and follow the
65
65
  [T3 runtime boundary](../axstack/references/t3-runtime.md). Ordinary local
66
66
  reading and writing does not require that launch reference.
67
67
 
68
- Authors must use async `delegate_task` with `isolation:"worktree"`, title = dispatch key
68
+ Authors must use async `delegate_task` with `isolation:"worktree"`, title = role-labelled dispatch key
69
69
  and stable `clientRequestId` under the runtime contract's fresh-checkout bootstrap,
70
70
  same-child repair and recorded launch fallback rules.
71
71
 
@@ -22,8 +22,8 @@ materially helps. Do not create a new profile.
22
22
  When dispatching `axstack-research-code`, dispatch `axstack-research-code-sol`
23
23
  independently on the same bounded brief without cross-reading. The driver
24
24
  reconciles findings per claim and never averages them. Record an intentionally
25
- absent Sol pair and proceed with the base seat alone. A configured optional
26
- Sol pair that fails to launch is fenced, recorded `absent (<reason>)`, and
25
+ absent `-sol` pair and proceed with the base seat alone. A configured optional
26
+ `-sol` pair that fails to launch is fenced, recorded `absent (<reason>)`, and
27
27
  named once in the next read-back, then skipped without relay or substitution.
28
28
  In mixed fan-out retain a Codex and a Claude seat or hold the affected work.
29
29
 
@@ -55,13 +55,13 @@ is part of research.
55
55
 
56
56
  - `axstack-research-requirements`: requirements and intent.
57
57
  - `axstack-research-code`: code behavior.
58
- - `axstack-research-code-sol`: independent Sol code investigation.
58
+ - `axstack-research-code-sol`: independent code investigation (Terra high in mixed, Sol high in codex-only).
59
59
  - `axstack-research-web`: web and external sources.
60
60
  - `axstack-research-x`: X (Twitter) posts and threads via Grok; cite post URLs and dates.
61
61
  - For broad codebase mapping, use `axstack-scout`.
62
62
  Never dispatch `axstack-explore-codebase` directly for broad mapping.
63
63
  - `axstack-explore-execution`: execution and runtime traces.
64
- - `axstack-explore-execution-sol`: independent Sol execution investigation.
64
+ - `axstack-explore-execution-sol`: independent execution investigation (Terra high in mixed, Sol high in codex-only).
65
65
 
66
66
  When dispatching `axstack-research-code` or `axstack-explore-execution`,
67
67
  dispatch its `-sol` pair independently on the same bounded brief without
@@ -36,7 +36,7 @@ This section applies to peer and authored PR modes.
36
36
  | --- | --- | --- |
37
37
  | `mixed` | `codex/sol` | `axstack-reviewer-secondary` (`claude/opus` high) |
38
38
  | `mixed` | `claude/opus` | `axstack-reviewer-primary` (`codex/sol` high) |
39
- | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/luna` xhigh) |
39
+ | `codex-only` | `codex/sol` | `axstack-reviewer-secondary` (`codex/terra` xhigh) |
40
40
  | `claude-only` | `claude/opus` | `axstack-reviewer-secondary` (`claude/sonnet` high) |
41
41
 
42
42
  The diligence receipt is separate and does not count as a reviewer receipt.
package/src/roles.js CHANGED
@@ -6,12 +6,12 @@ const PROVIDER_BOUNDS = Object.freeze({
6
6
 
7
7
  const CLASS_PROVIDERS = Object.freeze({
8
8
  fable: 'claude', opus: 'claude', sonnet: 'claude', haiku: 'claude',
9
- astra: 'codex', sol: 'codex', luna: 'codex',
9
+ astra: 'codex', sol: 'codex', luna: 'codex', terra: 'codex',
10
10
  });
11
11
 
12
12
  export function deriveModelClass(model) {
13
13
  if (typeof model !== 'string') return null;
14
- return model.match(/^gpt-(\d+(?:\.\d+)*)-(astra|sol|luna)$/)?.[2]
14
+ return model.match(/^gpt-(\d+(?:\.\d+)*)-(astra|sol|luna|terra)$/)?.[2]
15
15
  ?? model.match(/^claude-(fable|opus|sonnet|haiku)-/)?.[1]
16
16
  ?? null;
17
17
  }
@@ -22,7 +22,7 @@ const AUTHORED_ROUTES = Object.freeze({
22
22
  'claude/opus': ['axstack-reviewer-primary', 'codex/sol', 'high'],
23
23
  },
24
24
  'codex-only': {
25
- 'codex/sol': ['axstack-reviewer-secondary', 'codex/luna', 'xhigh'],
25
+ 'codex/sol': ['axstack-reviewer-secondary', 'codex/terra', 'xhigh'],
26
26
  },
27
27
  'claude-only': {
28
28
  'claude/opus': ['axstack-reviewer-secondary', 'claude/sonnet', 'high'],