axstack 0.20.3 → 0.20.5

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
@@ -56,6 +56,11 @@ axstack check --harness codex
56
56
  axstack install --harness codex --preset mixed --yes
57
57
  ```
58
58
 
59
+ Codex skills default to the shared `~/.agents/skills` root while its owned
60
+ `AGENTS.md` block stays under `$CODEX_HOME` (default `~/.codex`). A default
61
+ install safely retires only unchanged manifest-owned legacy Axstack skills;
62
+ use `--skills-dir` for an explicit target without automatic migration.
63
+
59
64
  Then open an Orca chat and ask for the relevant skill:
60
65
 
61
66
  ```text
package/bin/axstack.js CHANGED
@@ -8,6 +8,8 @@ import { join, resolve } from '../src/posixpath.js';
8
8
  import {
9
9
  checkInstructionBinding,
10
10
  installBundle,
11
+ readLegacySkillsManifest,
12
+ retireLegacySkills,
11
13
  uninstallBundle,
12
14
  validateBundle,
13
15
  } from '../src/installer.js';
@@ -57,7 +59,8 @@ Flags:
57
59
  profiles/presets/*.json role data. Defaults to the package root.
58
60
  --preset <name> Required routing preset: mixed, codex-only, or claude-only.
59
61
  Aliases: codex = codex-only; claude = claude-only.
60
- --skills-dir <dir> Explicit install target (required). Overrides --harness.
62
+ --skills-dir <dir> Explicit install target. Overrides harness skill defaults
63
+ and automatic legacy Codex skill retirement.
61
64
  --instructions <file>
62
65
  Instruction file to receive the owned routing block.
63
66
  Harness defaults: ~/.claude/CLAUDE.md for Claude,
@@ -71,6 +74,8 @@ Flags:
71
74
  Skip Claude Code user-settings management.
72
75
  --harness <name> Known harness (${harnessLocations().map((h) => h.harness).join(', ')}).
73
76
  Grok has no verified auto-discovery: --skills-dir is required.
77
+ Codex skills default to ~/.agents/skills; CODEX_HOME
78
+ continues to select its AGENTS.md configuration home.
74
79
  --force Overwrite/remove user-edited owned assets and take
75
80
  ownership of unknown files. Off by default.
76
81
  --yes Confirm writes inside your home directory. Temp dirs
@@ -203,13 +208,20 @@ function resolveHarnessTarget(harness) {
203
208
  );
204
209
  }
205
210
  if (entry.harness === 'codex') {
206
- // User skills live under $CODEX_HOME/skills; CODEX_HOME defaults to ~/.codex.
207
- const home = Bun.env.CODEX_HOME ? expandHome(Bun.env.CODEX_HOME) : join(homeDir(), '.codex');
208
- return join(home, 'skills');
211
+ // Axstack uses Codex's shared user-skill root. CODEX_HOME remains the
212
+ // configuration home for AGENTS.md, not an Axstack skill destination.
213
+ return join(homeDir(), '.agents', 'skills');
209
214
  }
210
215
  return expandHome(entry.skillsDir);
211
216
  }
212
217
 
218
+ function legacyCodexSkillsTarget() {
219
+ const codexHome = Bun.env.CODEX_HOME
220
+ ? expandHome(Bun.env.CODEX_HOME)
221
+ : join(homeDir(), '.codex');
222
+ return join(codexHome, 'skills');
223
+ }
224
+
213
225
  function resolveHarnessInstructions(harness) {
214
226
  if (harness === 'claude') return join(homeDir(), '.claude', 'CLAUDE.md');
215
227
  if (harness === 'codex') {
@@ -278,18 +290,45 @@ async function main() {
278
290
  : flags.harness
279
291
  ? resolveHarnessInstructions(flags.harness)
280
292
  : null;
293
+ const usesCodexDefault = flags.harness === 'codex' && !flags['skills-dir'];
294
+ const legacyCodexDir = usesCodexDefault ? legacyCodexSkillsTarget() : null;
295
+ const legacyCodexManifest = legacyCodexDir
296
+ ? await readLegacySkillsManifest(legacyCodexDir)
297
+ : null;
281
298
  const summary = await installBundle({
282
299
  bundleDir: flags.bundle ? resolve(flags.bundle) : PACKAGE_ROOT,
283
300
  skillsDir,
284
301
  preset: flags.preset,
285
302
  instructionsPath,
303
+ inheritedInstructions: legacyCodexManifest?.instructions,
286
304
  force: !!flags.force,
287
305
  yes: !!flags.yes,
288
306
  claude: resolveClaudeOption(flags, 'install'),
289
307
  log: (m) => { if (m !== 'plan complete') console.log(m); },
290
308
  });
309
+ const migrationReady = summary.instructions?.status !== 'conflict' &&
310
+ summary.roles?.ready !== false && summary.ownershipComplete;
311
+ if (usesCodexDefault && migrationReady) {
312
+ try {
313
+ summary.legacyCodex = await retireLegacySkills({
314
+ canonicalSkillsDir: skillsDir,
315
+ legacySkillsDir: legacyCodexDir,
316
+ acceptedInstructionTransfer: summary.acceptedInstructionTransfer,
317
+ yes: !!flags.yes,
318
+ });
319
+ } catch (err) {
320
+ summary.legacyCodex = {
321
+ removed: [], preserved: [], missing: [], skipped: false,
322
+ failed: true,
323
+ reason: `legacy Codex skills not retired: ${err.message}`,
324
+ };
325
+ }
326
+ } else if (usesCodexDefault) {
327
+ summary.legacyCodex = { removed: [], preserved: [], missing: [], held: true };
328
+ }
291
329
  const changed =
292
330
  summary.added.length + summary.updated.length + summary.removed.length +
331
+ (summary.legacyCodex?.removed.length ?? 0) +
293
332
  (['created', 'updated'].includes(summary.instructions?.status) ? 1 : 0);
294
333
  if (changed === 0) {
295
334
  console.log('Install complete: no changes (idempotent, everything unchanged).');
@@ -303,6 +342,31 @@ async function main() {
303
342
  console.log(`preserved user edits (use --force to overwrite): ${summary.preserved.join(', ')}`);
304
343
  }
305
344
  if (summary.stale.length) console.log(`stale owned files left on disk: ${summary.stale.join(', ')}`);
345
+ if (summary.legacyCodex && !summary.legacyCodex.skipped) {
346
+ if (summary.legacyCodex.held) {
347
+ console.log(
348
+ `legacy Codex skills preserved (retirement held): ${summary.legacyCodex.reason ?? 'canonical install has an unresolved conflict'}`,
349
+ );
350
+ if (summary.legacyCodex.failure) process.exitCode = 1;
351
+ }
352
+ if (summary.legacyCodex.failed) {
353
+ console.log(
354
+ `canonical install completed; legacy retirement failed: ${summary.legacyCodex.reason}`,
355
+ );
356
+ process.exitCode = 1;
357
+ }
358
+ if (summary.legacyCodex.removed.length) {
359
+ console.log(`legacy Codex skills retired: ${summary.legacyCodex.removed.join(', ')}`);
360
+ }
361
+ if (summary.legacyCodex.preserved.length) {
362
+ console.log(
363
+ `legacy Codex skills preserved (modified; resolve manually): ${summary.legacyCodex.preserved.join(', ')}`,
364
+ );
365
+ }
366
+ if (summary.legacyCodex.missing.length) {
367
+ console.log(`legacy Codex manifest entries already missing: ${summary.legacyCodex.missing.join(', ')}`);
368
+ }
369
+ }
306
370
  if (summary.instructions) {
307
371
  const i = summary.instructions;
308
372
  if (i.status === 'conflict') {
@@ -24,6 +24,8 @@ axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-
24
24
  - `--bundle` defaults to the package root and contains `skills/` plus
25
25
  `profiles/presets/*.json`.
26
26
  - `--skills-dir` is required unless a verified harness default resolves it.
27
+ Codex defaults to the shared `~/.agents/skills` root; an explicit override
28
+ remains authoritative and disables automatic legacy Codex-root retirement.
27
29
  - `--instructions` selects the instruction file that receives Axstack's owned
28
30
  marker block. `--harness claude` defaults to `~/.claude/CLAUDE.md`;
29
31
  `--harness codex` defaults to `$CODEX_HOME/AGENTS.md` or `~/.codex/AGENTS.md`;
@@ -34,6 +36,23 @@ axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-
34
36
  reads `~/.claude/skills`, so its own directory is only needed for the owned
35
37
  routing block; Antigravity (IDE and `agy` CLI) reads `~/.gemini/config/skills`
36
38
  only.
39
+
40
+ For a default Codex install, Axstack first installs and verifies the canonical
41
+ `~/.agents/skills` copy. It then retires only unchanged files owned by the
42
+ legacy `$CODEX_HOME/skills/.axstack-manifest.json`. Modified, missing, unowned,
43
+ or symlinked content is preserved or refused and reported; an instruction
44
+ conflict preserves the complete legacy install. An unchanged owned Codex
45
+ `AGENTS.md` binding is transferred to the canonical manifest without changing
46
+ the instruction bytes. Other harness ownership, settings, and inert profile
47
+ provenance remain untouched. Repeated installs verify the same canonical
48
+ preset and converge without duplicate skill entries.
49
+
50
+ If retirement leaves only the legacy manifest's Claude-settings ownership,
51
+ first confirm its `files` map is empty and it has no instruction or profile
52
+ conflict. Then finish that owner with
53
+ `axstack uninstall --skills-dir "${CODEX_HOME:-$HOME/.codex}/skills" --yes`;
54
+ the settings sidecar preserves the value while any other install still owns it.
55
+
37
56
  - `--claude-settings` and `--no-claude-settings` control the existing Claude
38
57
  Code subagent-default transaction. They do not configure Orca roles.
39
58
  - `--force` may replace an edited owned asset; it never adopts or removes
@@ -197,7 +216,7 @@ survives.
197
216
  | Harness | Default directory | Status |
198
217
  | --- | --- | --- |
199
218
  | Claude | `~/.claude/skills` | documented upstream |
200
- | Codex | `$CODEX_HOME/skills` (default `~/.codex/skills`) | documented upstream |
219
+ | Codex | `~/.agents/skills` | documented upstream |
201
220
  | OpenCode | `~/.config/opencode/skills` | documented upstream |
202
221
  | Antigravity | `~/.gemini/config/skills` | documented upstream |
203
222
  | Grok | explicit `--skills-dir` only | auto-discovery unverified |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks Orca capabilities.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -66,7 +66,17 @@ intent alone is insufficient. Conversely a completed run row does not prove exit
66
66
  If these facts remain unknown, report the hold at the durable decision location;
67
67
  do not silently stand down forever or replace a potentially live owner.
68
68
 
69
- Before PR admission, reconcile old pass resources. If three or more unreclaimed
69
+ Before PR admission, reconcile old pass resources and reclaim every safely
70
+ removable earlier pass workspace under the retirement guards below. This is
71
+ routine cleanup on every admitted pass; do not wait for the three-workspace
72
+ threshold to start cleanup. Save confirmed exit and ownership-release receipts
73
+ before removing each workspace. Preserve dirty, unpushed, evidence-bearing,
74
+ user-owned, active, or uncertain resources; the threshold never relaxes these guards.
75
+ Explicitly classified evidence may cease to block cleanup only after the
76
+ [private evidence archive](evidence-archive.md) is verified and its receipt is
77
+ read back from durable continuity. This never makes other dirt disposable.
78
+ After cleanup, re-list and count only the earlier pass workspaces still remaining.
79
+ If three or more unreclaimed
70
80
  earlier pass workspaces remain, disable only this automation through the native
71
81
  CLI, verify the disabled setting, save/report the cleanup hold, and admit no new
72
82
  PR jobs. Also pause on a confirmed cleanup failure or unresolved lane ownership.
@@ -146,12 +156,14 @@ publication receipt, hold, and next action. GitHub remains authoritative for
146
156
  open state, revisions, reviews, checks, and merge state.
147
157
 
148
158
  When the current event is handled, settle and release owned native resources.
149
- Preserve dirty worktrees, unpushed candidates, review evidence, pending
159
+ Preserve dirty worktrees, unpushed candidates, unarchived review evidence, pending
150
160
  external results, and user-owned work until durability and ownership are
151
161
  proven. Unknown liveness, `user_takeover`, and ambiguous publication likewise
152
162
  forbid cleanup. Here a pending external result means an unconfirmed publication
153
163
  or send outcome, not pending CI. Waiting state belongs in GitHub and the compact
154
164
  record, never in an idle model, per-PR timer, or polling loop.
165
+ Follow the [private evidence archive](evidence-archive.md) when evidence is the
166
+ only local state to preserve; archive success does not relax any other guard.
155
167
 
156
168
  ## Review and repair authority
157
169
 
@@ -216,8 +228,11 @@ self-close. Waiting PRs still occupy zero slots once their owned trees settle.
216
228
 
217
229
  Cleanup of PR-job setup shells stays scoped to positively identified owned unused
218
230
  setup shells: use the native exact-terminal close operation for each only.
219
- Preserve dirty worktrees, unpushed candidates, review evidence, user-owned
231
+ Preserve dirty worktrees, unpushed candidates, unarchived review evidence, user-owned
220
232
  terminals, unknown liveness, `user_takeover`, and ambiguous publication state.
233
+ Never classify all dirt as evidence. If explicitly classified evidence is the
234
+ last retention reason, apply and verify the [private evidence archive](evidence-archive.md),
235
+ update durable continuity, and read it back before native retirement.
221
236
 
222
237
  For the manager pass only, verify native run/workspace identity, exclusive
223
238
  automation ownership, no unsettled descendants, and a fresh terminal inventory
@@ -235,9 +250,12 @@ A failed or uncertain close is not proof of retirement. The next admitted pass
235
250
  reconciles prior retirement from native state before trusting saved intent.
236
251
  Retire only positively identified completed pass resources; do not kill another
237
252
  live or unknown manager. Remove an old pass worktree only with native cleanup
238
- after terminal retirement is confirmed and its Git state is clean, with no
239
- unpushed commits, retained evidence, children, or user-owned work. Never use
240
- recursive shell deletion. Failed cleanup remains recorded, not silently forgotten.
253
+ after terminal retirement is confirmed and it has no unpushed commits,
254
+ unarchived evidence, children, user-owned work, unknown files, or unexplained
255
+ dirty source. A verified evidence archive does not require otherwise clean Git
256
+ state, but every remaining change must still be positively classified and safe;
257
+ unknown dirt blocks removal. Never use recursive shell deletion. Failed cleanup
258
+ remains recorded, not silently forgotten.
241
259
 
242
260
  The activation canary must additionally prove distinct workspace IDs per pass,
243
261
  cross-workspace lane admission, self-retirement and absence after client reconnect,
@@ -0,0 +1,68 @@
1
+ # Private evidence archive
2
+
3
+ Use this only when local review or coordinator evidence is the last reason a
4
+ finished automation-owned worktree cannot be retired. It archives evidence; it
5
+ never decides that a terminal or worktree is safe to remove and never performs
6
+ native cleanup.
7
+
8
+ ## Eligibility
9
+
10
+ First prove the exact repository, PR, 40-character head SHA, Task/Dispatch,
11
+ workspace, terminal incarnation, automation ownership, descendant settlement,
12
+ and liveness from current native state. A manual chat, `user_takeover`, an
13
+ active or unknown task terminal, an unsettled descendant, unpushed commits,
14
+ dirty source, ambiguous publication, or an unknown file remains protected.
15
+
16
+ Classify each evidence file explicitly. Do not equate a dirty worktree with
17
+ disposable evidence, archive a whole worktree, or copy source changes as a way
18
+ to authorize deletion. Evidence may be archived while unrelated protected state
19
+ continues to block cleanup.
20
+
21
+ ## Archive and verify
22
+
23
+ Choose a configured absolute private archive root outside every disposable
24
+ worktree and outside public Orca artifacts. The root must be owned for this
25
+ purpose and inaccessible to group/other users. From the installed `axstack`
26
+ skill directory, run:
27
+
28
+ ```sh
29
+ bun scripts/archive-evidence.js \
30
+ --source-root <absolute-worktree-or-evidence-root> \
31
+ --archive-root <absolute-private-archive-root> \
32
+ --repo <owner/repository> --pr <number> --head <40-character-sha> \
33
+ --dispatch <exact-dispatch-id> \
34
+ --file <classified-relative-file> [--file <classified-relative-file> ...]
35
+ ```
36
+
37
+ The helper refuses path escapes, symlinks, non-private archive directories,
38
+ identity changes, and existing content that does not verify. It copies only the
39
+ listed regular files, writes them with private permissions, hashes their exact
40
+ bytes, and emits a JSON receipt containing the archive directory, manifest path,
41
+ manifest hash, and file count. Repeating the same command verifies the immutable
42
+ archive and returns the same receipt; it does not overwrite it.
43
+
44
+ Record the receipt plus the exact repo/PR/head/Task/Dispatch/workspace/terminal
45
+ identities in durable lane continuity, then read the continuity and archive
46
+ manifest back before cleanup. If either readback differs or is unavailable,
47
+ preserve the worktree.
48
+
49
+ ## Native retirement
50
+
51
+ Retire descendants before their parent. For each positively identified unused
52
+ setup shell, use the native exact-terminal close operation, then re-list native
53
+ state and require exit proof for that exact terminal incarnation. A task
54
+ terminal, manual chat, unexpected terminal, failed close, or uncertain exit
55
+ remains protected.
56
+
57
+ After receipt and continuity readback, if the only remaining Git dirt is the
58
+ verified archived untracked evidence, compare its current bytes with the
59
+ manifest again and unlink only those exact regular evidence files individually.
60
+ Never remove tracked or unknown files, directories, or any path whose hash now
61
+ differs. Re-read Git and native state; any remaining or uncertain dirt holds
62
+ retirement.
63
+
64
+ Only then use the version-matched Orca guide's native worktree cleanup operation
65
+ with the exact workspace identity. Never use shell recursive deletion and never
66
+ treat archive success as ownership, settlement, exit, or cleanup proof. Record
67
+ and verify native absence before advancing continuity; failure or uncertainty
68
+ preserves the resource.
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ writeFile,
11
+ } from 'node:fs/promises';
12
+
13
+ function isAbsolute(path) {
14
+ return path.startsWith('/');
15
+ }
16
+
17
+ function normalize(path) {
18
+ const absolute = isAbsolute(path);
19
+ const parts = [];
20
+ for (const part of path.split('/')) {
21
+ if (!part || part === '.') continue;
22
+ if (part === '..') {
23
+ if (parts.length > 0) parts.pop();
24
+ } else parts.push(part);
25
+ }
26
+ const normalized = `${absolute ? '/' : ''}${parts.join('/')}`;
27
+ return normalized || (absolute ? '/' : '.');
28
+ }
29
+
30
+ function join(...parts) {
31
+ return normalize(parts.filter(Boolean).join('/'));
32
+ }
33
+
34
+ function resolve(...parts) {
35
+ let path = '';
36
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
37
+ path = `${parts[i]}${path ? `/${path}` : ''}`;
38
+ if (isAbsolute(parts[i])) return normalize(path);
39
+ }
40
+ return normalize(`${process.cwd()}/${path}`);
41
+ }
42
+
43
+ function dirname(path) {
44
+ const normalized = normalize(path);
45
+ if (normalized === '/') return '/';
46
+ const index = normalized.lastIndexOf('/');
47
+ if (index < 0) return '.';
48
+ return index === 0 ? '/' : normalized.slice(0, index);
49
+ }
50
+
51
+ function relative(from, to) {
52
+ const fromParts = resolve(from).split('/').filter(Boolean);
53
+ const toParts = resolve(to).split('/').filter(Boolean);
54
+ let common = 0;
55
+ while (fromParts[common] === toParts[common] && common < fromParts.length) common += 1;
56
+ return [...Array(fromParts.length - common).fill('..'), ...toParts.slice(common)].join('/');
57
+ }
58
+
59
+ function fail(message) {
60
+ throw new Error(message);
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const values = { files: [] };
65
+ for (let i = 0; i < argv.length; i += 2) {
66
+ const flag = argv[i];
67
+ const value = argv[i + 1];
68
+ if (!flag?.startsWith('--') || value === undefined) fail(`invalid argument near ${flag ?? '(end)'}`);
69
+ const key = flag.slice(2);
70
+ if (key === 'file') values.files.push(value);
71
+ else if (['source-root', 'archive-root', 'repo', 'pr', 'head', 'dispatch'].includes(key)) {
72
+ if (values[key] !== undefined) fail(`duplicate --${key}`);
73
+ values[key] = value;
74
+ } else fail(`unknown argument: ${flag}`);
75
+ }
76
+ for (const key of ['source-root', 'archive-root', 'repo', 'pr', 'head', 'dispatch']) {
77
+ if (!values[key]) fail(`missing --${key}`);
78
+ }
79
+ if (values.files.length === 0) fail('at least one --file is required');
80
+ if (!isAbsolute(values['source-root']) || !isAbsolute(values['archive-root'])) {
81
+ fail('source and archive roots must be absolute');
82
+ }
83
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(values.repo)) fail('repo must be owner/name');
84
+ if (!/^[1-9][0-9]*$/.test(values.pr)) fail('pr must be a positive integer');
85
+ if (!/^[0-9a-f]{40}$/.test(values.head)) fail('head must be an exact 40-character lowercase SHA');
86
+ if (!/^[A-Za-z0-9_-]+$/.test(values.dispatch)) fail('dispatch contains unsafe characters');
87
+ values.files = [...new Set(values.files)].sort();
88
+ for (const file of values.files) {
89
+ const parts = file.split('/');
90
+ if (!file || isAbsolute(file) || parts.some((part) => !part || part === '.' || part === '..') || file.includes('\0')) {
91
+ fail(`unsafe evidence path: ${file || '(empty)'}`);
92
+ }
93
+ }
94
+ return values;
95
+ }
96
+
97
+ async function assertRealPath(path, expectedType) {
98
+ const st = await lstat(path).catch((err) => {
99
+ if (err?.code === 'ENOENT') return null;
100
+ throw err;
101
+ });
102
+ if (!st) fail(`missing ${expectedType}: ${path}`);
103
+ if (st.isSymbolicLink()) fail(`refusing symlink: ${path}`);
104
+ if (expectedType === 'directory' && !st.isDirectory()) fail(`not a directory: ${path}`);
105
+ if (expectedType === 'file' && !st.isFile()) fail(`not a regular file: ${path}`);
106
+ return st;
107
+ }
108
+
109
+ async function assertNoSymlinkComponents(root, rel) {
110
+ let current = root;
111
+ for (const part of rel.split('/')) {
112
+ current = join(current, part);
113
+ const st = await assertRealPath(current, current === join(root, rel) ? 'file' : 'directory');
114
+ if (st.isSymbolicLink()) fail(`refusing symlink: ${current}`);
115
+ }
116
+ }
117
+
118
+ async function assertSafeAncestors(path) {
119
+ const absolute = resolve(path);
120
+ let current = '/';
121
+ for (const part of absolute.split('/').filter(Boolean)) {
122
+ current = join(current, part);
123
+ const st = await lstat(current).catch((err) => {
124
+ if (err?.code === 'ENOENT') return null;
125
+ throw err;
126
+ });
127
+ if (!st) break;
128
+ if (st.isSymbolicLink()) fail(`refusing symlink path component: ${current}`);
129
+ }
130
+ }
131
+
132
+ async function ensurePrivateDir(path) {
133
+ const st = await lstat(path).catch((err) => {
134
+ if (err?.code === 'ENOENT') return null;
135
+ throw err;
136
+ });
137
+ if (st) {
138
+ if (st.isSymbolicLink() || !st.isDirectory()) fail(`unsafe archive directory: ${path}`);
139
+ if ((st.mode & 0o077) !== 0) fail(`archive directory must have private 0700 permissions: ${path}`);
140
+ return;
141
+ }
142
+ await mkdir(path, { mode: 0o700 });
143
+ await chmod(path, 0o700);
144
+ }
145
+
146
+ function sha256(bytes) {
147
+ const hasher = new Bun.CryptoHasher('sha256');
148
+ hasher.update(bytes);
149
+ return hasher.digest('hex');
150
+ }
151
+
152
+ async function collectSource(sourceRoot, files) {
153
+ await assertRealPath(sourceRoot, 'directory');
154
+ const collected = {};
155
+ for (const rel of files) {
156
+ await assertNoSymlinkComponents(sourceRoot, rel);
157
+ const bytes = await readFile(join(sourceRoot, rel));
158
+ collected[rel] = { bytes, sha256: sha256(bytes), size: bytes.length };
159
+ }
160
+ return collected;
161
+ }
162
+
163
+ function manifestBytes(identity, collected) {
164
+ const files = {};
165
+ for (const rel of Object.keys(collected).sort()) {
166
+ files[rel] = { sha256: collected[rel].sha256, size: collected[rel].size };
167
+ }
168
+ return Buffer.from(JSON.stringify({ version: 1, identity, files }, null, 2) + '\n');
169
+ }
170
+
171
+ async function verifyArchive(archiveDir, identity, collected) {
172
+ const archiveStat = await assertRealPath(archiveDir, 'directory');
173
+ if ((archiveStat.mode & 0o077) !== 0) fail(`archive permissions are not private: ${archiveDir}`);
174
+ const filesRoot = join(archiveDir, 'files');
175
+ const filesStat = await assertRealPath(filesRoot, 'directory');
176
+ if ((filesStat.mode & 0o077) !== 0) fail(`archive permissions are not private: ${filesRoot}`);
177
+ const manifestPath = join(archiveDir, 'manifest.json');
178
+ const expectedManifest = manifestBytes(identity, collected);
179
+ const actualManifest = await readFile(manifestPath);
180
+ if (!actualManifest.equals(expectedManifest)) fail(`archive manifest mismatch: ${manifestPath}`);
181
+ if (((await assertRealPath(manifestPath, 'file')).mode & 0o077) !== 0) {
182
+ fail(`archive manifest permissions are not private: ${manifestPath}`);
183
+ }
184
+ for (const [rel, expected] of Object.entries(collected)) {
185
+ const archived = join(archiveDir, 'files', rel);
186
+ await assertNoSymlinkComponents(filesRoot, rel);
187
+ const st = await assertRealPath(archived, 'file');
188
+ if ((st.mode & 0o077) !== 0) fail(`archived evidence permissions are not private: ${archived}`);
189
+ if (sha256(await readFile(archived)) !== expected.sha256) fail(`archived evidence hash mismatch: ${rel}`);
190
+ }
191
+ const actualFiles = await listArchiveFiles(archiveDir);
192
+ const expectedFiles = ['manifest.json', ...Object.keys(collected).map((rel) => `files/${rel}`)].sort();
193
+ if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
194
+ fail(`archive contains unexpected or missing files: ${archiveDir}`);
195
+ }
196
+ return {
197
+ archiveDir,
198
+ manifestPath,
199
+ manifestHash: sha256(actualManifest),
200
+ files: Object.keys(collected).length,
201
+ };
202
+ }
203
+
204
+ async function listArchiveFiles(root, prefix = '') {
205
+ const files = [];
206
+ const entries = await readdir(join(root, prefix), { withFileTypes: true });
207
+ entries.sort((a, b) => a.name.localeCompare(b.name));
208
+ for (const entry of entries) {
209
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
210
+ const st = await lstat(join(root, rel));
211
+ if (st.isSymbolicLink()) fail(`refusing symlink in archive: ${rel}`);
212
+ if (st.isDirectory()) files.push(...await listArchiveFiles(root, rel));
213
+ else if (st.isFile()) files.push(rel);
214
+ else fail(`unexpected archive entry: ${rel}`);
215
+ }
216
+ return files.sort();
217
+ }
218
+
219
+ async function main() {
220
+ const args = parseArgs(Bun.argv.slice(2));
221
+ const sourceRoot = resolve(args['source-root']);
222
+ const archiveRoot = resolve(args['archive-root']);
223
+ const sourceToArchive = relative(sourceRoot, archiveRoot);
224
+ const archiveToSource = relative(archiveRoot, sourceRoot);
225
+ if (
226
+ sourceToArchive === '' || (!sourceToArchive.startsWith('..') && !isAbsolute(sourceToArchive)) ||
227
+ archiveToSource === '' || (!archiveToSource.startsWith('..') && !isAbsolute(archiveToSource))
228
+ ) fail('source and archive roots must not contain each other');
229
+
230
+ const collected = await collectSource(sourceRoot, args.files);
231
+ const identity = {
232
+ repo: args.repo,
233
+ pr: Number(args.pr),
234
+ head: args.head,
235
+ dispatch: args.dispatch,
236
+ };
237
+ const repoSlug = args.repo.replace('/', '--');
238
+ const archiveDir = join(archiveRoot, repoSlug, `pr-${args.pr}`, args.head, args.dispatch);
239
+
240
+ await assertSafeAncestors(archiveRoot);
241
+ let current = archiveRoot;
242
+ const generated = [repoSlug, `pr-${args.pr}`, args.head];
243
+ await ensurePrivateDir(current);
244
+ for (const part of generated) {
245
+ current = join(current, part);
246
+ await ensurePrivateDir(current);
247
+ }
248
+ const existing = await lstat(archiveDir).catch((err) => {
249
+ if (err?.code === 'ENOENT') return null;
250
+ throw err;
251
+ });
252
+ if (existing) {
253
+ if (existing.isSymbolicLink() || !existing.isDirectory()) fail(`unsafe existing archive: ${archiveDir}`);
254
+ const receipt = await verifyArchive(archiveDir, identity, collected);
255
+ console.log(JSON.stringify({ status: 'verified', ...receipt }));
256
+ return;
257
+ }
258
+
259
+ const tempDir = `${archiveDir}.tmp-${process.pid}`;
260
+ if (await lstat(tempDir).catch((err) => err?.code === 'ENOENT' ? null : Promise.reject(err))) {
261
+ fail(`temporary archive already exists: ${tempDir}`);
262
+ }
263
+ await mkdir(join(tempDir, 'files'), { recursive: true, mode: 0o700 });
264
+ await chmod(tempDir, 0o700);
265
+ await chmod(join(tempDir, 'files'), 0o700);
266
+ try {
267
+ for (const [rel, entry] of Object.entries(collected)) {
268
+ const dest = join(tempDir, 'files', rel);
269
+ const parent = dirname(dest);
270
+ await mkdir(parent, { recursive: true, mode: 0o700 });
271
+ let cursor = join(tempDir, 'files');
272
+ const parentRel = relative(cursor, parent);
273
+ for (const part of parentRel === '' ? [] : parentRel.split('/')) {
274
+ cursor = join(cursor, part);
275
+ await chmod(cursor, 0o700);
276
+ }
277
+ await writeFile(dest, entry.bytes, { flag: 'wx', mode: 0o600 });
278
+ await chmod(dest, 0o600);
279
+ }
280
+ const manifestPath = join(tempDir, 'manifest.json');
281
+ await writeFile(manifestPath, manifestBytes(identity, collected), { flag: 'wx', mode: 0o600 });
282
+ await chmod(manifestPath, 0o600);
283
+ await rename(tempDir, archiveDir);
284
+ } catch (err) {
285
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
286
+ throw err;
287
+ }
288
+
289
+ const receipt = await verifyArchive(archiveDir, identity, collected);
290
+ console.log(JSON.stringify({ status: 'archived', ...receipt }));
291
+ }
292
+
293
+ try {
294
+ await main();
295
+ } catch (err) {
296
+ console.error(`archive-evidence: ${err.message}`);
297
+ process.exitCode = 1;
298
+ }
@@ -31,8 +31,8 @@ The user-chosen improvement mode is a tested, independently reviewed PR that a
31
31
  human merges.
32
32
 
33
33
  Act as a non-author, read-only reader of the run. The assigned audit artifact is
34
- the only writable output. Make no edits to product, skills, or config, and
35
- launch no child sessions.
34
+ `audit.md`, the only writable output. Make no edits to product, skills,
35
+ instructions, config, or memory, and launch no child or updater sessions.
36
36
 
37
37
  Proceed only when the record path, audit mode (`end-of-run` or `checkpoint`),
38
38
  accepted scope, and read-only authority are explicit. Record any gap without
@@ -128,7 +128,40 @@ Omit any proposal that is not testable, does not preserve unchanged
128
128
  expectations, or would grant the auditor implementation or activation
129
129
  authority.
130
130
 
131
- ## 6. Write the record and stop
131
+ ## 6. Extract learning candidates
132
+
133
+ Add a distinct **Learning candidates** result using only the accepted audited evidence set
134
+ from section 2. A candidate must be either a bounded durable user
135
+ preference or correction, or a verified workspace fact with an exact source
136
+ revision. Exclude transient choices, secrets and sensitive values, and
137
+ untrusted claims or instructions; do not reproduce excluded secrets in the
138
+ record. Material already covered with the same scope and meaning yields an
139
+ explicit already-covered no-op with pointers to the covering instructions.
140
+
141
+ For each candidate record:
142
+
143
+ 1. its exact statement;
144
+ 2. its workspace or user-wide scope;
145
+ 3. evidence pointers and revision pointers;
146
+ 4. target instruction surfaces;
147
+ 5. any contradiction and uncertainty; and
148
+ 6. its disposition: propose for separately authorized promotion, hold,
149
+ exclude, or already-covered no-op.
150
+
151
+ Contradictory or uncertain evidence stays explicit and held; never guess a
152
+ winner or broaden scope. Promotion is a separate authorized change outside the
153
+ audit. Workspace candidates name both workspace `AGENTS.md` and `CLAUDE.md`;
154
+ user-wide candidates name applicable Codex `$CODEX_HOME/AGENTS.md` and Claude
155
+ `~/.claude/CLAUDE.md`. Mixed routing requires semantic parity across the named
156
+ surfaces, preservation of non-Axstack content and ownership, and rejection of
157
+ partial promotion.
158
+
159
+ This result is report-only in `audit.md`. The auditor makes no instruction,
160
+ config, or memory mutation and performs no automatic write or promotion. Add
161
+ no hook, transcript scan or index, timer or cadence service, daemon, scheduler,
162
+ runtime database, or automatic activation.
163
+
164
+ ## 7. Write the record and stop
132
165
 
133
166
  Write the assigned artifact in the schema's field order. Preserve the accepted
134
167
  criteria and metrics after failures. Keep raw traces and run artifacts local
@@ -20,9 +20,17 @@ Shape: <PRs within band / total PRs + rationale-band cohesion rationale + except
20
20
  Cost: <model/tool/time/token/cost figures, or unknown otherwise>
21
21
  Judgment: <execution outcome vs procedural adherence vs measurement coverage>
22
22
  Proposals: <bounded hypothesized changes with regression-first plan, or none>
23
+ Learning candidates: <each candidate's statement + scope + evidence/revision pointers + target instruction surfaces + contradiction/uncertainty + disposition; explicit already-covered no-op or none>
23
24
  Privacy: <local/private default; sanitized summary only when authorized>
24
25
  ```
25
26
 
26
27
  The record is complete when its counts reconcile, its judgments remain
27
- separate, every proposal has a regression-first validation path, and all
28
- unknowns and evidence limitations are explicit.
28
+ separate, every proposal has a regression-first validation path, every learning
29
+ candidate is bounded to accepted audited evidence, and all unknowns and
30
+ evidence limitations are explicit. Learning candidates are report-only:
31
+ promotion is separately authorized, workspace scope targets both workspace
32
+ `AGENTS.md` and `CLAUDE.md`, and user-wide scope targets applicable Codex
33
+ `$CODEX_HOME/AGENTS.md` and Claude `~/.claude/CLAUDE.md`. Mixed routing requires
34
+ semantic parity, preservation of non-Axstack content and ownership, and
35
+ rejection of partial promotion; the auditor writes only `audit.md` and makes no
36
+ instruction, config, or memory mutation.
package/src/installer.js CHANGED
@@ -430,6 +430,21 @@ export async function checkInstructionBinding({ skillsDir, instructionsPath } =
430
430
  return { status: 'owned', path: instructionsFile };
431
431
  }
432
432
 
433
+ export async function readLegacySkillsManifest(skillsDir) {
434
+ const root = resolve(skillsDir);
435
+ const rootStat = await lstat(root).catch((err) => {
436
+ if (err?.code === 'ENOENT') return null;
437
+ throw err;
438
+ });
439
+ if (rootStat?.isSymbolicLink()) {
440
+ throw new Error(`legacy Codex skills root is a symlink at ${root}; refusing`);
441
+ }
442
+ if (rootStat && !rootStat.isDirectory()) {
443
+ throw new Error(`legacy Codex skills root is not a directory at ${root}; refusing`);
444
+ }
445
+ return readManifest(root);
446
+ }
447
+
433
448
  async function assertFileSnapshot(dest, expected) {
434
449
  let current = null;
435
450
  try {
@@ -447,6 +462,7 @@ export async function installBundle({
447
462
  skillsDir,
448
463
  preset,
449
464
  instructionsPath = null,
465
+ inheritedInstructions = null,
450
466
  force = false,
451
467
  yes = false,
452
468
  claude = null,
@@ -492,7 +508,10 @@ export async function installBundle({
492
508
  ? 'legacy Paseo profile provenance retained inert; see legacy cleanup guidance in docs/installation.md'
493
509
  : null;
494
510
 
495
- const boundInstructions = prevManifest.instructions ?? { path: null, hash: null };
511
+ const ownInstructions = prevManifest.instructions ?? { path: null, hash: null };
512
+ const usesInheritedInstructions = ownInstructions.path === null &&
513
+ inheritedInstructions?.path === instructionsFile;
514
+ const boundInstructions = usesInheritedInstructions ? inheritedInstructions : ownInstructions;
496
515
  let existingInstructionsRaw = null;
497
516
  let instructionPlan = null;
498
517
  let legacyInstructionNote = null;
@@ -685,6 +704,20 @@ export async function installBundle({
685
704
  }
686
705
  return {
687
706
  ...summary,
707
+ ownershipComplete: desired.every(({ rel, content }) =>
708
+ installedHashes[rel] === hashContent(content)),
709
+ // Retirement may retry after a prior canonical install succeeded. The
710
+ // accepted plan proves either fresh inheritance or existing canonical
711
+ // ownership of the same legacy-bound path; forced edits prove neither.
712
+ acceptedInstructionTransfer: inheritedInstructions?.path === instructionsFile &&
713
+ boundInstructions.path === instructionsFile &&
714
+ instructionPlan?.action !== 'conflict' && !instructionPlan?.forced
715
+ ? {
716
+ path: instructionsFile,
717
+ legacyHash: inheritedInstructions.hash,
718
+ canonicalHash: nextInstructions.hash,
719
+ }
720
+ : null,
688
721
  preset: selectedPreset,
689
722
  roles: roleReadiness,
690
723
  claudeSettings: claudePlan.report,
@@ -969,6 +1002,144 @@ export async function uninstallBundle({
969
1002
  }
970
1003
  }
971
1004
 
1005
+ // Retire the obsolete Codex-root copy only after a canonical Axstack install
1006
+ // is present and byte-for-byte verified. This deliberately handles skill
1007
+ // payloads only: instruction files, Claude settings, and historical profile
1008
+ // ownership may belong to another harness and remain bound to the old manifest.
1009
+ export async function retireLegacySkills({
1010
+ canonicalSkillsDir,
1011
+ legacySkillsDir,
1012
+ acceptedInstructionTransfer = null,
1013
+ yes = false,
1014
+ } = {}) {
1015
+ if (!canonicalSkillsDir || !legacySkillsDir) {
1016
+ throw new Error('legacy retirement requires canonical and legacy skills directories');
1017
+ }
1018
+ const canonicalRoot = await canonicalTargetDir(resolve(canonicalSkillsDir));
1019
+ await readLegacySkillsManifest(legacySkillsDir);
1020
+ const legacyRoot = await canonicalTargetDir(resolve(legacySkillsDir));
1021
+ if (canonicalRoot === legacyRoot) return { removed: [], preserved: [], missing: [], skipped: true };
1022
+ assertOutsideHome(legacyRoot, { yes, kind: 'legacy Codex skills directory' });
1023
+
1024
+ const canonicalManifest = await readManifest(canonicalRoot);
1025
+ if (!canonicalManifest || Object.keys(canonicalManifest.files).length === 0) {
1026
+ throw new Error('legacy Codex skills not retired: canonical install has no ownership manifest');
1027
+ }
1028
+ for (const [rel, expectedHash] of Object.entries(canonicalManifest.files)) {
1029
+ const { current } = await readOwnedTarget(canonicalRoot, rel);
1030
+ if (current === null || hashContent(current) !== expectedHash) {
1031
+ throw new Error(`legacy Codex skills not retired: canonical verification failed for ${rel}`);
1032
+ }
1033
+ }
1034
+
1035
+ const legacyManifest = await readManifest(legacyRoot);
1036
+ if (!legacyManifest || Object.keys(legacyManifest.files).length === 0) {
1037
+ return { removed: [], preserved: [], missing: [], skipped: true };
1038
+ }
1039
+
1040
+ // Validate every owned destination before deleting the first one. In
1041
+ // particular, one symlink refuses the entire retirement rather than being
1042
+ // followed or leaving a partly retired legacy install.
1043
+ const protectedGroups = new Set();
1044
+ for (const [rel, ownedHash] of Object.entries(legacyManifest.files)) {
1045
+ const target = await readOwnedTarget(legacyRoot, rel);
1046
+ if (target.current !== null && hashContent(target.current) !== ownedHash) {
1047
+ protectedGroups.add(rel.split('/')[0]);
1048
+ }
1049
+ }
1050
+
1051
+ const summary = { removed: [], preserved: [], missing: [], skipped: false };
1052
+ const remainingFiles = { ...legacyManifest.files };
1053
+ let remainingInstructions = legacyManifest.instructions;
1054
+ if (legacyManifest.instructions?.path !== null) {
1055
+ const canonicalInstructions = canonicalManifest.instructions;
1056
+ const transferAccepted =
1057
+ acceptedInstructionTransfer?.path === legacyManifest.instructions.path &&
1058
+ acceptedInstructionTransfer?.legacyHash === legacyManifest.instructions.hash &&
1059
+ acceptedInstructionTransfer?.canonicalHash === canonicalInstructions?.hash &&
1060
+ canonicalInstructions?.path === legacyManifest.instructions.path;
1061
+ if (!transferAccepted) {
1062
+ return {
1063
+ ...summary,
1064
+ held: true,
1065
+ failure: true,
1066
+ reason: 'legacy instruction ownership is bound to a different instruction file or was not accepted by the canonical install',
1067
+ };
1068
+ }
1069
+ const instructionsRaw = await readFile(await canonicalInstructionFile(canonicalInstructions.path), 'utf8');
1070
+ const installedBlock = locateInstructionBlock(instructionsRaw);
1071
+ if (
1072
+ installedBlock === null || hashContent(installedBlock.block) !== canonicalInstructions.hash
1073
+ ) {
1074
+ throw new Error('legacy instruction ownership not transferred: canonical binding is not verified');
1075
+ }
1076
+ remainingInstructions = { path: null, hash: null };
1077
+ }
1078
+ const manifestFile = join(legacyRoot, '.axstack-manifest.json');
1079
+ const manifestBefore = await readFile(manifestFile);
1080
+ const manifestMode = (await stat(manifestFile)).mode & 0o777;
1081
+ const deletedFiles = [];
1082
+ try {
1083
+ for (const [rel, ownedHash] of Object.entries(legacyManifest.files)) {
1084
+ if (protectedGroups.has(rel.split('/')[0])) {
1085
+ summary.preserved.push(rel);
1086
+ continue;
1087
+ }
1088
+ const { dest, current, mode } = await readOwnedTarget(legacyRoot, rel);
1089
+ if (current === null) {
1090
+ summary.missing.push(rel);
1091
+ delete remainingFiles[rel];
1092
+ } else if (hashContent(current) === ownedHash) {
1093
+ deletedFiles.push({ dest, bytes: current, mode });
1094
+ await rm(dest);
1095
+ await pruneEmptyParents(dest, legacyRoot);
1096
+ delete remainingFiles[rel];
1097
+ summary.removed.push(rel);
1098
+ } else {
1099
+ summary.preserved.push(rel);
1100
+ }
1101
+ }
1102
+
1103
+ const hasProfiles = Object.keys(legacyManifest.profiles?.entries ?? {}).length > 0;
1104
+ const hasOtherOwnership = hasProfiles || legacyManifest.claudeSettings?.path !== null ||
1105
+ remainingInstructions.path !== null;
1106
+ if (Object.keys(remainingFiles).length === 0 && !hasOtherOwnership) {
1107
+ await rm(manifestFile);
1108
+ } else {
1109
+ await writeManifest(legacyRoot, {
1110
+ ...legacyManifest,
1111
+ files: remainingFiles,
1112
+ instructions: remainingInstructions,
1113
+ });
1114
+ }
1115
+ return summary;
1116
+ } catch (err) {
1117
+ const rollbackErrors = [];
1118
+ for (const { dest, bytes, mode } of deletedFiles.reverse()) {
1119
+ try {
1120
+ await writeAtomic(dest, bytes, mode === null ? {} : { mode });
1121
+ } catch (restoreErr) {
1122
+ rollbackErrors.push(`${dest}: ${restoreErr?.message ?? restoreErr}`);
1123
+ }
1124
+ }
1125
+ try {
1126
+ const currentManifest = await readFile(manifestFile).catch((readErr) => {
1127
+ if (readErr?.code === 'ENOENT') return null;
1128
+ throw readErr;
1129
+ });
1130
+ if (currentManifest?.equals(manifestBefore) !== true) {
1131
+ await writeAtomic(manifestFile, manifestBefore, { mode: manifestMode });
1132
+ }
1133
+ } catch (restoreErr) {
1134
+ rollbackErrors.push(`${manifestFile}: ${restoreErr?.message ?? restoreErr}`);
1135
+ }
1136
+ if (rollbackErrors.length > 0) {
1137
+ err.message += ` (incomplete rollback; manual repair needed: ${rollbackErrors.join('; ')})`;
1138
+ }
1139
+ throw err;
1140
+ }
1141
+ }
1142
+
972
1143
  async function pruneEmptyParents(file, stopDir) {
973
1144
  let dir = dirname(file);
974
1145
  while (dir !== stopDir && withinRoot(dir, stopDir)) {
package/src/locations.js CHANGED
@@ -17,11 +17,11 @@ export function harnessLocations() {
17
17
  },
18
18
  {
19
19
  harness: 'codex',
20
- skillsDir: '$CODEX_HOME/skills (default ~/.codex/skills)',
20
+ skillsDir: '~/.agents/skills',
21
21
  discovery: 'docs',
22
- source: 'https://developers.openai.com/codex/skills',
22
+ source: 'https://learn.chatgpt.com/docs/build-skills',
23
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.',
24
+ 'Codex user skills use the shared ~/.agents/skills root. CODEX_HOME still selects AGENTS.md. Pass --skills-dir to override skill placement and skip automatic legacy migration.',
25
25
  },
26
26
  {
27
27
  harness: 'opencode',