axstack 0.9.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/bin/axstack.js +396 -0
  4. package/docs/installation.md +239 -0
  5. package/docs/workflows.md +220 -0
  6. package/package.json +40 -0
  7. package/profiles/presets/claude-only.json +194 -0
  8. package/profiles/presets/codex-only.json +194 -0
  9. package/profiles/presets/mixed.json +194 -0
  10. package/skills/axstack/SKILL.md +81 -0
  11. package/skills/axstack/references/automations.md +368 -0
  12. package/skills/axstack/references/candidate-publication.md +45 -0
  13. package/skills/axstack/references/contracts.md +102 -0
  14. package/skills/axstack/references/lifecycle.md +137 -0
  15. package/skills/axstack/references/orca-runtime.md +109 -0
  16. package/skills/axstack/references/pr-shape.md +39 -0
  17. package/skills/axstack/references/routing.md +129 -0
  18. package/skills/axstack/references/run-record.md +109 -0
  19. package/skills/axstack-align/SKILL.md +121 -0
  20. package/skills/axstack-audit/SKILL.md +137 -0
  21. package/skills/axstack-audit/references/record.md +28 -0
  22. package/skills/axstack-debug/SKILL.md +157 -0
  23. package/skills/axstack-debug/references/packet.md +80 -0
  24. package/skills/axstack-explain/SKILL.md +66 -0
  25. package/skills/axstack-explain/references/visual-qa.md +15 -0
  26. package/skills/axstack-implement/SKILL.md +164 -0
  27. package/skills/axstack-improve/SKILL.md +69 -0
  28. package/skills/axstack-relay/SKILL.md +102 -0
  29. package/skills/axstack-research/SKILL.md +57 -0
  30. package/skills/axstack-research/references/checklist.md +25 -0
  31. package/skills/axstack-review/SKILL.md +343 -0
  32. package/skills/axstack-spec/SKILL.md +67 -0
  33. package/skills/axstack-tickets/SKILL.md +86 -0
  34. package/skills/axstack-watch/SKILL.md +160 -0
  35. package/skills/axstack-watch/references/repair-publication.md +69 -0
  36. package/skills/axstack-watch/references/watch-runtime.md +60 -0
  37. package/src/capabilities.js +138 -0
  38. package/src/claude-settings.js +230 -0
  39. package/src/installer.js +980 -0
  40. package/src/instructions.js +100 -0
  41. package/src/locations.js +43 -0
  42. package/src/manifest.js +251 -0
  43. package/src/posixpath.js +108 -0
  44. package/src/roles.js +142 -0
@@ -0,0 +1,100 @@
1
+ // Pure planning and byte-preserving edits for the Axstack-owned routing block.
2
+ import { hashContent } from './manifest.js';
3
+ import { join } from './posixpath.js';
4
+
5
+ const BEGIN = '<!-- axstack:begin v1 -->';
6
+ const END = '<!-- axstack:end -->';
7
+
8
+ export function renderInstructionBlock(skillsDir) {
9
+ return [
10
+ BEGIN,
11
+ `Use Axstack for engineering work. Load \`${join(skillsDir, 'axstack', 'SKILL.md')}\` to route the request.`,
12
+ 'Route every subagent, delegated worker, reviewer, and cross-harness dispatch through Orca orchestration via the `orca` CLI and its `orca-cli` / `orchestration` skills so the work stays visible.',
13
+ 'Do not use a harness native subagent tool for delegated work.',
14
+ END,
15
+ ].join('\n');
16
+ }
17
+
18
+ export function locateInstructionBlock(text) {
19
+ const begins = [...text.matchAll(/<!--\s*axstack:begin\b[^>]*-->/g)];
20
+ const ends = [...text.matchAll(/<!--\s*axstack:end\s*-->/g)];
21
+ if (begins.length === 0 && ends.length === 0) return null;
22
+ if (begins.length !== 1 || ends.length !== 1) {
23
+ throw new Error('invalid Axstack instruction markers: duplicate or incomplete marker');
24
+ }
25
+ if (!/^<!-- axstack:begin v\d+ -->$/.test(begins[0][0])) {
26
+ throw new Error('invalid Axstack instruction begin marker');
27
+ }
28
+ const start = begins[0].index;
29
+ const end = ends[0].index + ends[0][0].length;
30
+ if (begins[0].index >= ends[0].index) {
31
+ throw new Error('invalid Axstack instruction markers: nested or reversed marker');
32
+ }
33
+ const body = text.slice(start, end);
34
+ if (/<!--\s*axstack:(?:begin|end)\b/.test(body.slice(begins[0][0].length, -ends[0][0].length))) {
35
+ throw new Error('invalid Axstack instruction markers: nested marker');
36
+ }
37
+ return { start, end, block: body };
38
+ }
39
+
40
+ export function planInstruction({ text, block, ownership, force = false }) {
41
+ if (text === null) {
42
+ if (ownership) return { action: 'conflict', reason: 'owned instruction file is missing' };
43
+ return { action: 'created', block, separation: '' };
44
+ }
45
+ const located = locateInstructionBlock(text);
46
+ if (!located) {
47
+ if (ownership) return { action: 'conflict', reason: 'owned instruction block is missing' };
48
+ const separation = text.length === 0 ? '' : text.endsWith('\n') ? '\n' : '\n\n';
49
+ return { action: 'updated', block, separation, append: true };
50
+ }
51
+ if (!ownership) {
52
+ return { action: 'conflict', reason: 'instruction block is present but unowned' };
53
+ }
54
+ if (hashContent(located.block) !== ownership.hash) {
55
+ if (!force) return { action: 'conflict', reason: 'owned instruction block was edited' };
56
+ }
57
+ if (located.block === block) return { action: 'unchanged', block, separation: ownership.separation ?? '' };
58
+ return {
59
+ action: 'updated',
60
+ block,
61
+ separation: ownership.separation ?? '',
62
+ start: located.start,
63
+ end: located.end,
64
+ forced: hashContent(located.block) !== ownership.hash,
65
+ };
66
+ }
67
+
68
+ export function applyInstructionPlan(text, plan) {
69
+ if (plan.action === 'created') return plan.block;
70
+ if (plan.action !== 'updated') return text;
71
+ if (plan.append) return `${text}${plan.separation}${plan.block}`;
72
+ return `${text.slice(0, plan.start)}${plan.block}${text.slice(plan.end)}`;
73
+ }
74
+
75
+ export function stripInstructionBlock(text, ownership, { force = false } = {}) {
76
+ const located = locateInstructionBlock(text);
77
+ if (!located || (!force && hashContent(located.block) !== ownership.hash)) {
78
+ return { text, removed: false, conflict: true };
79
+ }
80
+ const separation = ownership.separation ?? '';
81
+ const prefix = text.slice(0, located.start);
82
+ if (separation && !prefix.endsWith(separation)) {
83
+ return { text, removed: false, conflict: true };
84
+ }
85
+ return {
86
+ text: prefix.slice(0, prefix.length - separation.length) + text.slice(located.end),
87
+ removed: true,
88
+ block: located.block,
89
+ };
90
+ }
91
+
92
+ export function findLegacyRoutingLines(text) {
93
+ const located = locateInstructionBlock(text);
94
+ const outside = located
95
+ ? text.slice(0, located.start) + text.slice(located.end)
96
+ : text;
97
+ return outside.split('\n').filter((line) =>
98
+ /\bhaoshoku\b.*\b(?:rout\w*|skills?)\b|\b(?:planning-advisor|review-code|paseo-pr-review|paseo-pr-babysit)\b/i.test(line),
99
+ );
100
+ }
@@ -0,0 +1,43 @@
1
+ // Supported harness skill locations. `discovery` records how the default
2
+ // directory was established: upstream docs, local CLI help, or unverified.
3
+ // `source` is the verified primary-source page for the default (checked
4
+ // 2026-09-13; re-check local `<tool> --help` when in doubt). Unverified
5
+ // harnesses (notably Grok) have no automatic destination: callers must pass
6
+ // an explicit --skills-dir override. Installing files alone never proves
7
+ // harness behavior; that needs a real end-to-end run.
8
+ export function harnessLocations() {
9
+ return [
10
+ {
11
+ harness: 'claude',
12
+ skillsDir: '~/.claude/skills',
13
+ discovery: 'docs',
14
+ source: 'https://docs.claude.com/en/api/agent-sdk/skills',
15
+ notes:
16
+ 'User skills directory from Anthropic docs; confirm with local help. Pass --skills-dir to override.',
17
+ },
18
+ {
19
+ harness: 'codex',
20
+ skillsDir: '$CODEX_HOME/skills (default ~/.codex/skills)',
21
+ discovery: 'docs',
22
+ source: 'https://developers.openai.com/codex/skills',
23
+ notes:
24
+ 'User skills live under $CODEX_HOME/skills per OpenAI docs (CODEX_HOME defaults to ~/.codex). The CLI honors $CODEX_HOME when set. Pass --skills-dir to override.',
25
+ },
26
+ {
27
+ harness: 'opencode',
28
+ skillsDir: '~/.config/opencode/skills',
29
+ discovery: 'docs',
30
+ source: 'https://opencode.ai/docs/skills',
31
+ notes:
32
+ 'Global skills directory from OpenCode docs; confirm with local help. Pass --skills-dir to override.',
33
+ },
34
+ {
35
+ harness: 'grok',
36
+ skillsDir: '(explicit --skills-dir required)',
37
+ discovery: 'unverified',
38
+ source: null,
39
+ notes:
40
+ 'No verified skill directory for Grok; automatic discovery is unverified so an explicit --skills-dir override is required.',
41
+ },
42
+ ];
43
+ }
@@ -0,0 +1,251 @@
1
+ // Ownership-manifest helpers: sha256 hashes plus atomic manifest read/write.
2
+ // The manifest lives at <skillsDir>/.axstack-manifest.json and records the
3
+ // exact bytes Axstack installed, so updates and uninstalls only touch
4
+ // unchanged owned assets.
5
+ import { chmod, lstat, mkdir, open, rename, readFile, rm, stat } from 'node:fs/promises';
6
+ import { isAbsolute, join } from './posixpath.js';
7
+
8
+ export const MANIFEST_NAME = '.axstack-manifest.json';
9
+ export const MANIFEST_TMP_NAME = '.axstack-manifest.json.tmp';
10
+ export const MANIFEST_VERSION = 1;
11
+
12
+ export function hashContent(content) {
13
+ // Byte-for-byte compatible with the pre-migration implementation:
14
+ // strings hash as UTF-8; every other input (including Buffers read from
15
+ // skill files) hashes as its JSON serialization. Do NOT "fix" Buffers to
16
+ // hash as raw bytes without a manifest migration: every recorded
17
+ // ownership hash would stop matching and unchanged owned files would lose
18
+ // ownership. Proven by fixtures/old-impl under both runtimes.
19
+ const input = typeof content === 'string' ? content : JSON.stringify(content);
20
+ const hasher = new Bun.CryptoHasher('sha256');
21
+ hasher.update(input, 'utf8');
22
+ return hasher.digest('hex');
23
+ }
24
+
25
+ // Deterministic object hash for profile entries (key order independent).
26
+ export function hashObject(value) {
27
+ return hashContent(stableStringify(value));
28
+ }
29
+
30
+ function stableStringify(value) {
31
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
32
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
33
+ const keys = Object.keys(value).sort();
34
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
35
+ }
36
+
37
+ export function manifestPath(skillsDir) {
38
+ return join(skillsDir, MANIFEST_NAME);
39
+ }
40
+
41
+ export function assertSafeRel(rel) {
42
+ if (
43
+ !rel ||
44
+ typeof rel !== 'string' ||
45
+ isAbsolute(rel) ||
46
+ rel.split('/').includes('..') ||
47
+ rel.includes('\0') ||
48
+ /^[a-zA-Z]:/.test(rel) ||
49
+ rel === MANIFEST_NAME
50
+ ) {
51
+ throw new Error(`unsafe manifest path rejected: ${rel || '(empty)'}`);
52
+ }
53
+ }
54
+
55
+ function isHashMap(value) {
56
+ return (
57
+ typeof value === 'object' &&
58
+ value !== null &&
59
+ !Array.isArray(value) &&
60
+ Object.values(value).every((v) => typeof v === 'string')
61
+ );
62
+ }
63
+
64
+ // Normalized shape:
65
+ // { version, files, profiles: { path, preset, entries },
66
+ // claudeSettings: { path }, instructions: { path, hash, separation? } }.
67
+ // `profiles.path` binds owned profile hashes to the canonical config file
68
+ // they were installed into; `path: null` marks legacy unbound entries, which
69
+ // callers must reset rather than honor for removal.
70
+ function normalizeManifest(parsed) {
71
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
72
+ throw new Error('invalid ownership manifest: expected a JSON object');
73
+ }
74
+ if (parsed.version !== MANIFEST_VERSION) {
75
+ throw new Error(
76
+ `unsupported ownership manifest version: ${JSON.stringify(parsed.version)}`,
77
+ );
78
+ }
79
+ if (!isHashMap(parsed.files)) {
80
+ throw new Error('invalid ownership manifest: files must be an object of path hashes');
81
+ }
82
+ const rawClaudeSettings = parsed.claudeSettings ?? { path: null };
83
+ if (
84
+ typeof rawClaudeSettings !== 'object' || rawClaudeSettings === null ||
85
+ Array.isArray(rawClaudeSettings) ||
86
+ (rawClaudeSettings.path !== null && typeof rawClaudeSettings.path !== 'string')
87
+ ) {
88
+ throw new Error('invalid ownership manifest: claudeSettings must bind a config path');
89
+ }
90
+ const rawInstructions = parsed.instructions ?? { path: null, hash: null };
91
+ if (
92
+ typeof rawInstructions !== 'object' || rawInstructions === null ||
93
+ Array.isArray(rawInstructions) ||
94
+ !(
95
+ (rawInstructions.path === null && rawInstructions.hash === null) ||
96
+ (typeof rawInstructions.path === 'string' &&
97
+ isAbsolute(rawInstructions.path) &&
98
+ typeof rawInstructions.hash === 'string')
99
+ ) ||
100
+ (rawInstructions.separation !== undefined &&
101
+ !['', '\n', '\n\n'].includes(rawInstructions.separation))
102
+ ) {
103
+ throw new Error('invalid ownership manifest: instructions must bind an absolute path and hash');
104
+ }
105
+ const instructions = rawInstructions.separation === undefined
106
+ ? { path: rawInstructions.path, hash: rawInstructions.hash }
107
+ : {
108
+ path: rawInstructions.path,
109
+ hash: rawInstructions.hash,
110
+ separation: rawInstructions.separation,
111
+ };
112
+ for (const rel of Object.keys(parsed.files)) assertSafeRel(rel);
113
+ const rawProfiles = parsed.profiles ?? { path: null, preset: null, entries: {} };
114
+ if (isHashMap(rawProfiles)) {
115
+ return {
116
+ version: MANIFEST_VERSION,
117
+ files: { ...parsed.files },
118
+ profiles: { path: null, preset: null, entries: { ...rawProfiles } },
119
+ claudeSettings: { path: rawClaudeSettings.path },
120
+ instructions,
121
+ };
122
+ }
123
+ if (
124
+ typeof rawProfiles === 'object' &&
125
+ rawProfiles !== null &&
126
+ (rawProfiles.path === null || typeof rawProfiles.path === 'string') &&
127
+ (rawProfiles.preset === undefined || rawProfiles.preset === null || typeof rawProfiles.preset === 'string') &&
128
+ isHashMap(rawProfiles.entries ?? {})
129
+ ) {
130
+ return {
131
+ version: MANIFEST_VERSION,
132
+ files: { ...parsed.files },
133
+ profiles: {
134
+ path: rawProfiles.path,
135
+ preset: rawProfiles.preset ?? null,
136
+ entries: { ...(rawProfiles.entries ?? {}) },
137
+ },
138
+ claudeSettings: { path: rawClaudeSettings.path },
139
+ instructions,
140
+ };
141
+ }
142
+ throw new Error('invalid ownership manifest: profiles must bind a config path to id hashes');
143
+ }
144
+
145
+ export async function readManifest(skillsDir) {
146
+ const dest = manifestPath(skillsDir);
147
+ try {
148
+ if ((await lstat(dest)).isSymbolicLink()) {
149
+ throw new Error('unsafe ownership manifest: manifest path is a symlink; refusing');
150
+ }
151
+ } catch (err) {
152
+ if (err?.code === 'ENOENT') return null;
153
+ throw err?.code ? new Error(`cannot read ownership manifest: ${err.message}`) : err;
154
+ }
155
+ let raw;
156
+ try {
157
+ raw = await readFile(dest, 'utf8');
158
+ } catch (err) {
159
+ if (err?.code === 'ENOENT') return null;
160
+ throw new Error(`cannot read ownership manifest: ${err.message}`);
161
+ }
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(raw);
165
+ } catch {
166
+ throw new Error('ownership manifest is corrupt; refusing to proceed');
167
+ }
168
+ return normalizeManifest(parsed);
169
+ }
170
+
171
+ export async function writeManifest(skillsDir, manifest) {
172
+ await mkdir(skillsDir, { recursive: true });
173
+ const dest = manifestPath(skillsDir);
174
+ // Fixed temp name (single-writer assumption for a user-invoked installer;
175
+ // a failed run leaves either the old manifest or no manifest, and retries
176
+ // converge). A fixed name also keeps manifest-write failures testable.
177
+ const tmp = join(skillsDir, MANIFEST_TMP_NAME);
178
+ const normalized = normalizeManifest(manifest);
179
+ let existingMode = null;
180
+ try {
181
+ existingMode = (await stat(dest)).mode & 0o777;
182
+ } catch (err) {
183
+ if (err?.code !== 'ENOENT') throw new Error(`cannot write ownership manifest: ${err.message}`);
184
+ }
185
+ try {
186
+ await writeFileExclusive(tmp, JSON.stringify(normalized, null, 2) + '\n');
187
+ } catch (err) {
188
+ if (/temporary file already exists/.test(err?.message ?? '')) throw err;
189
+ throw new Error(`cannot write ownership manifest: ${err?.message ?? err}`);
190
+ }
191
+ try {
192
+ await rename(tmp, dest);
193
+ } catch (err) {
194
+ try {
195
+ await rm(tmp);
196
+ } catch {
197
+ // best effort cleanup of a temp file this run created
198
+ }
199
+ throw new Error(`cannot write ownership manifest: ${err?.message ?? err}`);
200
+ }
201
+ try {
202
+ await chmod(dest, existingMode ?? 0o600);
203
+ } catch (err) {
204
+ throw new Error(`wrote ownership manifest but could not set permissions: ${err.message}`);
205
+ }
206
+ }
207
+
208
+ // Create a temp file exclusively with restrictive permissions. Pre-existing
209
+ // temp entries (files, directories, symlinks) are refused — never followed,
210
+ // truncated, or removed — so a planted temp path cannot redirect writes.
211
+ // This guards pre-existing entries, not an active concurrent attacker.
212
+ export async function writeFileExclusive(tmpPath, bytes) {
213
+ let preexisting = null;
214
+ try {
215
+ preexisting = await lstat(tmpPath);
216
+ } catch (err) {
217
+ if (err?.code !== 'ENOENT') throw err;
218
+ }
219
+ if (preexisting) {
220
+ throw new Error(
221
+ `temporary file already exists at ${tmpPath}; refusing to overwrite it (remove it if it is a stale leftover)`,
222
+ );
223
+ }
224
+ let handle;
225
+ try {
226
+ handle = await open(tmpPath, 'wx', 0o600);
227
+ } catch (err) {
228
+ if (err?.code === 'EEXIST') {
229
+ throw new Error(
230
+ `temporary file already exists at ${tmpPath}; refusing to overwrite it (remove it if it is a stale leftover)`,
231
+ );
232
+ }
233
+ throw err;
234
+ }
235
+ try {
236
+ await handle.writeFile(bytes);
237
+ await handle.close();
238
+ } catch (err) {
239
+ try {
240
+ await handle.close();
241
+ } catch {
242
+ // ignore close failure; original error matters
243
+ }
244
+ try {
245
+ await rm(tmpPath); // only unlink: this run created it via wx
246
+ } catch {
247
+ // best effort cleanup of a temp file this run created
248
+ }
249
+ throw err;
250
+ }
251
+ }
@@ -0,0 +1,108 @@
1
+ // Small explicit POSIX path helper covering only the operations the
2
+ // installer uses (join, resolve, relative, isAbsolute, basename, dirname).
3
+ // Backslashes are ordinary filename characters on POSIX and are NEVER
4
+ // treated as separators (this is the documented divergence from pathe,
5
+ // which normalizes Windows separators). No syscalls, no dependencies.
6
+ export const sep = '/';
7
+
8
+ export function isAbsolute(p) {
9
+ return typeof p === 'string' && p.startsWith('/');
10
+ }
11
+
12
+ function normalizeSegments(parts, { allowAboveRoot }) {
13
+ const out = [];
14
+ for (const part of parts) {
15
+ if (part === '' || part === '.') continue;
16
+ if (part === '..') {
17
+ if (out.length > 0 && out[out.length - 1] !== '..') out.pop();
18
+ else if (allowAboveRoot) out.push('..');
19
+ } else {
20
+ out.push(part);
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export function normalize(p) {
27
+ if (typeof p !== 'string') throw new TypeError('path must be a string');
28
+ if (p === '') return '.';
29
+ const absolute = p.startsWith('/');
30
+ const trailing = p.length > 1 && p.endsWith('/');
31
+ const segs = normalizeSegments(p.split('/'), { allowAboveRoot: !absolute });
32
+ let result = (absolute ? '/' : '') + segs.join('/');
33
+ if (result === '') result = absolute ? '/' : '.';
34
+ else if (trailing && result !== '/') result += '/';
35
+ return result;
36
+ }
37
+
38
+ export function join(...parts) {
39
+ const strings = parts.filter((p) => typeof p === 'string' && p !== '');
40
+ if (strings.length === 0) return '.';
41
+ const joined = strings.join('/');
42
+ let result = normalize(joined);
43
+ // join preserves a trailing slash from the last segment.
44
+ const last = strings[strings.length - 1];
45
+ if (last.endsWith('/') && !result.endsWith('/')) result += '/';
46
+ return result;
47
+ }
48
+
49
+ export function resolveFrom(cwd, ...paths) {
50
+ let resolved = '';
51
+ let isAbs = false;
52
+ const all = [...paths];
53
+ for (let i = all.length - 1; i >= 0; i--) {
54
+ const p = all[i];
55
+ if (typeof p !== 'string' || p === '') continue;
56
+ resolved = resolved ? `${p}/${resolved}` : p;
57
+ if (p.startsWith('/')) {
58
+ isAbs = true;
59
+ break;
60
+ }
61
+ }
62
+ if (!isAbs) resolved = resolved ? `${cwd}/${resolved}` : String(cwd);
63
+ const result = normalize(resolved);
64
+ return result.length > 1 && result.endsWith('/') ? result.slice(0, -1) : result;
65
+ }
66
+
67
+ export function resolve(...paths) {
68
+ return resolveFrom(Bun.cwd, ...paths);
69
+ }
70
+
71
+ export function relative(from, to) {
72
+ const a = resolve(from);
73
+ const b = resolve(to);
74
+ if (a === b) return '';
75
+ const aSegs = a.split('/').filter((s) => s !== '');
76
+ const bSegs = b.split('/').filter((s) => s !== '');
77
+ let common = 0;
78
+ while (common < aSegs.length && common < bSegs.length && aSegs[common] === bSegs[common]) {
79
+ common++;
80
+ }
81
+ const up = aSegs.length - common;
82
+ return [...Array(up).fill('..'), ...bSegs.slice(common)].join('/');
83
+ }
84
+
85
+ function stripTrailing(p) {
86
+ let end = p.length;
87
+ while (end > 1 && p[end - 1] === '/') end--;
88
+ return p.slice(0, end);
89
+ }
90
+
91
+ // No `ext` parameter: no caller strips extensions (YAGNI).
92
+ export function basename(p) {
93
+ if (typeof p !== 'string') throw new TypeError('path must be a string');
94
+ const stripped = stripTrailing(p);
95
+ if (stripped === '') return '';
96
+ const idx = stripped.lastIndexOf('/');
97
+ return idx === -1 ? stripped : stripped.slice(idx + 1);
98
+ }
99
+
100
+ export function dirname(p) {
101
+ if (typeof p !== 'string') throw new TypeError('path must be a string');
102
+ const stripped = stripTrailing(p);
103
+ if (stripped === '') return '.';
104
+ const idx = stripped.lastIndexOf('/');
105
+ if (idx === -1) return '.';
106
+ if (idx === 0) return '/';
107
+ return stripped.slice(0, idx);
108
+ }
package/src/roles.js ADDED
@@ -0,0 +1,142 @@
1
+ const PROVIDER_BOUNDS = Object.freeze({
2
+ mixed: new Set(['codex', 'claude']),
3
+ 'codex-only': new Set(['codex']),
4
+ 'claude-only': new Set(['claude']),
5
+ });
6
+
7
+ const AUTHORED_ROUTES = Object.freeze({
8
+ mixed: {
9
+ 'codex/gpt-5.6-sol': ['axstack-reviewer-secondary', 'claude/claude-opus-5', 'medium'],
10
+ 'claude/claude-opus-5': ['axstack-reviewer-primary', 'codex/gpt-5.6-sol', 'medium'],
11
+ },
12
+ 'codex-only': {
13
+ 'codex/gpt-5.6-sol': ['axstack-reviewer-secondary', 'codex/gpt-5.6-terra', 'xhigh'],
14
+ },
15
+ 'claude-only': {
16
+ 'claude/claude-opus-5': ['axstack-reviewer-secondary', 'claude/claude-sonnet-5', 'xhigh'],
17
+ },
18
+ });
19
+
20
+ export function assertBundleRoles(roles) {
21
+ if (!Array.isArray(roles) || roles.length === 0) {
22
+ throw new Error('invalid bundle roles: expected a non-empty roles array');
23
+ }
24
+ const ids = new Set();
25
+ for (const role of roles) {
26
+ if (!role || typeof role !== 'object' || Array.isArray(role)) {
27
+ throw new Error('invalid bundle roles: every role must be an object');
28
+ }
29
+ for (const field of ['id', 'name', 'provider']) {
30
+ if (typeof role[field] !== 'string' || role[field].trim() === '') {
31
+ throw new Error(`invalid bundle roles: every role needs a non-empty ${field} string`);
32
+ }
33
+ }
34
+ if (!role.id.startsWith('axstack-')) {
35
+ throw new Error('invalid bundle roles: every role needs a namespaced axstack-* id');
36
+ }
37
+ if (ids.has(role.id)) throw new Error(`invalid bundle roles: duplicate role ID ${role.id}`);
38
+ ids.add(role.id);
39
+ if (!Object.hasOwn(role, 'model')) {
40
+ throw new Error('invalid bundle roles: model must be a non-empty string or explicit null');
41
+ }
42
+ if (role.model !== null && (typeof role.model !== 'string' || role.model.trim() === '')) {
43
+ throw new Error('invalid bundle roles: model must be a non-empty string or explicit null');
44
+ }
45
+ for (const field of ['icon', 'color', 'modeId', 'thinkingOptionId', 'notes']) {
46
+ if (role[field] !== undefined && typeof role[field] !== 'string') {
47
+ throw new Error(`invalid bundle roles: ${field} must be a string when present`);
48
+ }
49
+ }
50
+ if (
51
+ role.featureValues !== undefined &&
52
+ (typeof role.featureValues !== 'object' || role.featureValues === null || Array.isArray(role.featureValues))
53
+ ) {
54
+ throw new Error('invalid bundle roles: featureValues must be an object when present');
55
+ }
56
+ }
57
+ }
58
+
59
+ export function assessRoleReadiness(roles, preset) {
60
+ assertBundleRoles(roles);
61
+ const bounds = PROVIDER_BOUNDS[preset];
62
+ if (!bounds) throw new Error(`cannot assess readiness for unknown preset: ${preset}`);
63
+ const byId = new Map(roles.map((role) => [role.id, role]));
64
+ const gaps = [];
65
+ const isIntentionalAbsence = (role) => role.model === null && (
66
+ (preset === 'mixed' && role.id === 'axstack-checker') ||
67
+ (preset === 'codex-only' && role.id === 'axstack-advisor-fable') ||
68
+ (preset === 'claude-only' && role.id === 'axstack-advisor-astra')
69
+ );
70
+ for (const role of roles) {
71
+ if (!bounds.has(role.provider)) {
72
+ gaps.push(`${role.id} provider ${JSON.stringify(role.provider)} is outside ${preset} bounds (${[...bounds].join('|')})`);
73
+ }
74
+ if (!isIntentionalAbsence(role) && (typeof role.model !== 'string' || role.model.trim() === '')) {
75
+ gaps.push(`${role.id} requires a configured model`);
76
+ }
77
+ }
78
+
79
+ for (const id of ['axstack-reviewer-opus', 'axstack-reviewer-sol']) {
80
+ if (byId.has(id)) gaps.push(`legacy reviewer remains: ${id}`);
81
+ }
82
+ const primary = byId.get('axstack-reviewer-primary');
83
+ const secondary = byId.get('axstack-reviewer-secondary');
84
+ if (primary && secondary) {
85
+ if (primary.model === secondary.model) gaps.push('reviewer pair must use two distinct models');
86
+ if (preset === 'mixed' && primary.provider === secondary.provider) {
87
+ gaps.push('mixed reviewer pair must use different providers');
88
+ }
89
+ }
90
+
91
+ const author = byId.get('axstack-author');
92
+ if (author) {
93
+ const authorRoute = `${author.provider}/${author.model}`;
94
+ const route = AUTHORED_ROUTES[preset]?.[authorRoute];
95
+ if (!route) {
96
+ gaps.push(`authored routing gap: unsupported axstack-author route ${authorRoute}`);
97
+ } else {
98
+ const [reviewerId, reviewerRoute, effort] = route;
99
+ const reviewer = byId.get(reviewerId);
100
+ if (!reviewer || `${reviewer.provider}/${reviewer.model}` !== reviewerRoute || reviewer.thinkingOptionId !== effort) {
101
+ gaps.push(`authored routing gap: ${reviewerId} must be ${reviewerRoute}/${effort} for author ${authorRoute}`);
102
+ }
103
+ }
104
+ }
105
+ return { ready: gaps.length === 0, gaps };
106
+ }
107
+
108
+ export function installedRoleBytes(preset, roles) {
109
+ return Buffer.from(JSON.stringify({ version: 1, preset, roles }, null, 2) + '\n');
110
+ }
111
+
112
+ export function assessInstalledRoleSnapshot(bytes, expectedPreset, expectedRoles) {
113
+ let snapshot;
114
+ try {
115
+ snapshot = JSON.parse(bytes.toString());
116
+ } catch {
117
+ return { ready: false, gaps: ['installed axstack/roles.json is not valid JSON'] };
118
+ }
119
+ if (snapshot?.version !== 1 || snapshot?.preset !== expectedPreset || !Array.isArray(snapshot?.roles)) {
120
+ return {
121
+ ready: false,
122
+ gaps: [`installed role snapshot must be version 1 for preset ${expectedPreset}`],
123
+ };
124
+ }
125
+ try {
126
+ assertBundleRoles(expectedRoles);
127
+ const readiness = assessRoleReadiness(snapshot.roles, expectedPreset);
128
+ const installedIds = new Set(snapshot.roles.map((role) => role.id));
129
+ const expectedIds = new Set(expectedRoles.map((role) => role.id));
130
+ const gaps = [];
131
+ for (const role of expectedRoles) {
132
+ if (!installedIds.has(role.id)) gaps.push(`missing selected bundle role: ${role.id}`);
133
+ }
134
+ for (const role of snapshot.roles) {
135
+ if (!expectedIds.has(role.id)) gaps.push(`unexpected installed role: ${role.id}`);
136
+ }
137
+ gaps.push(...readiness.gaps);
138
+ return { ready: gaps.length === 0, gaps };
139
+ } catch (error) {
140
+ return { ready: false, gaps: [error.message] };
141
+ }
142
+ }