axstack 0.20.30 → 0.20.31

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.
@@ -90,8 +90,10 @@ busy, missing, fenced, protected, or permission-held driver is never interrupted
90
90
  or replaced.
91
91
 
92
92
  The driver records one Notification policy: `axstack-relay` Telegram home only
93
- for a user-decision hold, first merge-ready and fully merged milestones (at most
94
- two), or serious-risk hold. Quiet ticks never notify.
93
+ for a user-decision hold (including spec and npm approval), merge-ready or
94
+ merged milestones (at most two across implementation and release), or a
95
+ serious-risk hold.
96
+ Quiet ticks never notify.
95
97
 
96
98
  The driver alone routes repair. Re-read remote head/base and native ownership.
97
99
  Independent PRs may repair in parallel in separate Orca child worktrees within
@@ -116,8 +118,13 @@ re-read all feedback and approvals at the current head before readiness.
116
118
  Re-reading approvals checks current state, not re-requesting review from a
117
119
  human who already approved.
118
120
 
119
- Stop the chosen wake only when every watched PR is merged or closed, the user cancels,
120
- or it expires. The driver stops a harness-native wake and verifies its stop receipt;
121
+ Stop the chosen wake only when every watched PR is merged or closed and the
122
+ run's release step is settled or not applicable, the user cancels, or it
123
+ expires. Without an Autopilot or Release record, the release step is not
124
+ applicable to this watch. A required PR closed without merging records a
125
+ decision hold and the wake remains active while unexpired until the user
126
+ resolves scope, cancels, or the wake expires; the run is not release-eligible.
127
+ The driver stops a harness-native wake and verifies its stop receipt;
121
128
  a failed or uncertain stop is a hold. Re-read membership and confirm no ambiguous
122
129
  publication or unsettled pass; cancellation
123
130
  prevents new work but does not prove running workers exited. The observer may
package/src/installer.js CHANGED
@@ -635,8 +635,16 @@ export async function installBundle({
635
635
  const newIds = new Set(bundle.bundleRoles.map((role) => role.id));
636
636
  const addedRoleIds = bundle.bundleRoles.filter((role) => !oldIds.has(role.id)).map((role) => role.id);
637
637
  const removedRoleIds = old.roles.filter((role) => role?.id && !newIds.has(role.id)).map((role) => role.id);
638
+ const oldById = new Map(old.roles.filter((role) => role?.id).map((role) => [role.id, role]));
639
+ const changedRoleModels = bundle.bundleRoles.flatMap((role) => {
640
+ const previous = oldById.get(role.id);
641
+ if (!previous) return [];
642
+ const fields = ['modelClass', 'model'].filter((field) => previous[field] !== role[field]);
643
+ return fields.length ? [`${role.id} (${fields.join(', ')})`] : [];
644
+ });
638
645
  summary.addedRoleIds = addedRoleIds;
639
646
  summary.removedRoleIds = removedRoleIds;
647
+ summary.changedRoleModels = changedRoleModels;
640
648
  }
641
649
  } catch { /* No reliable role-ID diff for malformed prior bytes. */ }
642
650
  }
package/src/roles.js CHANGED
@@ -4,19 +4,33 @@ const PROVIDER_BOUNDS = Object.freeze({
4
4
  'claude-only': new Set(['claude']),
5
5
  });
6
6
 
7
+ const CLASS_PROVIDERS = Object.freeze({
8
+ fable: 'claude', opus: 'claude', sonnet: 'claude', haiku: 'claude',
9
+ astra: 'codex', sol: 'codex', luna: 'codex',
10
+ });
11
+
12
+ export function deriveModelClass(model) {
13
+ if (typeof model !== 'string') return null;
14
+ return model.match(/^gpt-(\d+(?:\.\d+)*)-(astra|sol|luna)$/)?.[2]
15
+ ?? model.match(/^claude-(fable|opus|sonnet|haiku)-/)?.[1]
16
+ ?? null;
17
+ }
18
+
7
19
  const AUTHORED_ROUTES = Object.freeze({
8
20
  mixed: {
9
- 'codex/gpt-6-sol': ['axstack-reviewer-secondary', 'claude/claude-opus-5-5', 'medium'],
10
- 'claude/claude-opus-5-5': ['axstack-reviewer-primary', 'codex/gpt-6-sol', 'high'],
21
+ 'codex/sol': ['axstack-reviewer-secondary', 'claude/opus', 'medium'],
22
+ 'claude/opus': ['axstack-reviewer-primary', 'codex/sol', 'high'],
11
23
  },
12
24
  'codex-only': {
13
- 'codex/gpt-6-sol': ['axstack-reviewer-secondary', 'codex/gpt-6-luna', 'xhigh'],
25
+ 'codex/sol': ['axstack-reviewer-secondary', 'codex/luna', 'xhigh'],
14
26
  },
15
27
  'claude-only': {
16
- 'claude/claude-opus-5-5': ['axstack-reviewer-secondary', 'claude/claude-sonnet-5-5', 'high'],
28
+ 'claude/opus': ['axstack-reviewer-secondary', 'claude/sonnet', 'high'],
17
29
  },
18
30
  });
19
31
 
32
+ const roleClass = (role) => role.modelClass ?? deriveModelClass(role.model);
33
+
20
34
  export function assertBundleRoles(roles) {
21
35
  if (!Array.isArray(roles) || roles.length === 0) {
22
36
  throw new Error('invalid bundle roles: expected a non-empty roles array');
@@ -36,12 +50,22 @@ export function assertBundleRoles(roles) {
36
50
  }
37
51
  if (ids.has(role.id)) throw new Error(`invalid bundle roles: duplicate role ID ${role.id}`);
38
52
  ids.add(role.id);
39
- if (!Object.hasOwn(role, 'model')) {
53
+ const hasClass = Object.hasOwn(role, 'modelClass');
54
+ if (hasClass && (typeof role.modelClass !== 'string' || !Object.hasOwn(CLASS_PROVIDERS, role.modelClass))) {
55
+ throw new Error(`invalid bundle roles: ${role.id} has an unsupported modelClass`);
56
+ }
57
+ if (hasClass && role.provider !== CLASS_PROVIDERS[role.modelClass]) {
58
+ throw new Error(`invalid bundle roles: ${role.id} model class does not match provider`);
59
+ }
60
+ if (!hasClass && !Object.hasOwn(role, 'model')) {
40
61
  throw new Error('invalid bundle roles: model must be a non-empty string or explicit null');
41
62
  }
42
- if (role.model !== null && (typeof role.model !== 'string' || role.model.trim() === '')) {
63
+ if (Object.hasOwn(role, 'model') && role.model !== null && (typeof role.model !== 'string' || role.model.trim() === '')) {
43
64
  throw new Error('invalid bundle roles: model must be a non-empty string or explicit null');
44
65
  }
66
+ if (hasClass && typeof role.model === 'string' && deriveModelClass(role.model) !== role.modelClass) {
67
+ throw new Error(`invalid bundle roles: ${role.id} model pin does not match model class`);
68
+ }
45
69
  for (const field of ['icon', 'color', 'modeId', 'thinkingOptionId', 'notes']) {
46
70
  if (role[field] !== undefined && typeof role[field] !== 'string') {
47
71
  throw new Error(`invalid bundle roles: ${field} must be a string when present`);
@@ -75,7 +99,7 @@ export function assessRoleReadiness(roles, preset) {
75
99
  if (!bounds.has(role.provider)) {
76
100
  gaps.push(`${role.id} provider ${JSON.stringify(role.provider)} is outside ${preset} bounds (${[...bounds].join('|')})`);
77
101
  }
78
- if (!isIntentionalAbsence(role) && (typeof role.model !== 'string' || role.model.trim() === '')) {
102
+ if (!isIntentionalAbsence(role) && !Object.hasOwn(role, 'modelClass') && (typeof role.model !== 'string' || role.model.trim() === '')) {
79
103
  gaps.push(`${role.id} requires a configured model`);
80
104
  }
81
105
  }
@@ -86,7 +110,11 @@ export function assessRoleReadiness(roles, preset) {
86
110
  const primary = byId.get('axstack-reviewer-primary');
87
111
  const secondary = byId.get('axstack-reviewer-secondary');
88
112
  if (primary && secondary) {
89
- if (primary.model === secondary.model) gaps.push('reviewer pair must use two distinct models');
113
+ const bothResolved = typeof primary.model === 'string' && typeof secondary.model === 'string';
114
+ const sameReviewer = bothResolved
115
+ ? primary.model === secondary.model
116
+ : roleClass(primary) !== null && roleClass(primary) === roleClass(secondary);
117
+ if (sameReviewer) gaps.push('reviewer pair must use two distinct models');
90
118
  if (preset === 'mixed' && primary.provider === secondary.provider) {
91
119
  gaps.push('mixed reviewer pair must use different providers');
92
120
  }
@@ -94,14 +122,14 @@ export function assessRoleReadiness(roles, preset) {
94
122
 
95
123
  const author = byId.get('axstack-author');
96
124
  if (author) {
97
- const authorRoute = `${author.provider}/${author.model}`;
125
+ const authorRoute = `${author.provider}/${roleClass(author) ?? author.model}`;
98
126
  const route = AUTHORED_ROUTES[preset]?.[authorRoute];
99
127
  if (!route) {
100
128
  gaps.push(`authored routing gap: unsupported axstack-author route ${authorRoute}`);
101
129
  } else {
102
130
  const [reviewerId, reviewerRoute, effort] = route;
103
131
  const reviewer = byId.get(reviewerId);
104
- if (!reviewer || `${reviewer.provider}/${reviewer.model}` !== reviewerRoute || reviewer.thinkingOptionId !== effort) {
132
+ if (!reviewer || `${reviewer.provider}/${roleClass(reviewer)}` !== reviewerRoute || reviewer.thinkingOptionId !== effort) {
105
133
  gaps.push(`authored routing gap: ${reviewerId} must be ${reviewerRoute}/${effort} for author ${authorRoute}`);
106
134
  }
107
135
  }