axstack 0.20.2 → 0.20.4

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
@@ -1,142 +1,129 @@
1
1
  # Axstack
2
2
 
3
- Axstack is a standalone toolkit for finishing agreed engineering work with less
4
- supervision while retaining independent review. Chat drives execution; a Bun
5
- CLI installs owned skills and role data and checks capabilities. Axstack has no
6
- daemon, scheduler, runtime database, or workflow state machine.
7
-
8
- Orca is the only supported active runtime. Its installed, version-matched
9
- `orchestration` and `orca-cli` guides own worktrees, sessions, supervised
10
- dispatch, messages, settlement, and handoff mechanics. Axstack owns scope,
11
- role choices, evidence, review policy, and one private derived run record.
12
-
13
- ## How a run works
14
-
15
- Invoke the needed phase directly: `axstack-align`, `axstack-spec`,
16
- `axstack-tickets`, `axstack-implement`, `axstack-review`, and `axstack-watch`.
17
- Direct `axstack-research`, `axstack-explain`,
18
- `axstack-improve`, and `axstack-debug` routes need no spec ceremony. `axstack-relay` remains an
19
- optional inline route for explicit messages and authorized notifications; an
20
- unavailable or legacy-runtime-only relay falls back to the current conversation
21
- without changing authority.
22
-
23
- 1. Classify new engineering work as substantial, small, or unclear with a brief
24
- reason. Small, bounded one-PR work uses the current request or selected issue
25
- as a snapshotted small-change intent;
26
- substantial or stacked work needs an approved spec and matching ticket map.
27
- Substantial work uses Linear by default, or explicitly selected GitHub Issues
28
- or repository Markdown, as its authoritative spec and capability tracker.
29
- 2. The current chat drives on whatever model runs it; there is no driver
30
- profile. Bind each ready task to the selected role snapshot and an authoritative Orca
31
- Run, Task, and Dispatch. Exactly one writer owns a candidate at a time. All
32
- subagent and delegated worker dispatches go through Orca orchestration rather than
33
- harness-native subagent tools.
34
- 3. Peer PRs receive both configured independent reviewer roles. Authored PRs
35
- receive one eligible reviewer from the selected preset's explicit mapping
36
- and actual author provenance. Every review binds the exact head and base.
37
- Stable IDs are `axstack-reviewer-primary` and `axstack-reviewer-secondary`.
38
- 4. Accepted repairs return to the same author where its session and evidence
39
- remain valid. The human merges by default, bottom-up for a stack.
40
- 5. Full handoff requires explicit recipient acceptance of the exact scope and
41
- authority before ownership changes. Ordinary resume reconciles the current
42
- owner instead of replacing it.
43
-
44
- Active PR fanout is dependency- and capacity-driven; there is no fixed count.
45
- Each PR has one theme and a measured size under the shared
46
- [PR-shape policy](skills/axstack/references/pr-shape.md). Routine shape and
47
- fanout choices remain autonomous inside approved scope. Material scope,
48
- serious-risk, unavailable-model, and human-merge holds remain explicit.
49
- The autonomous driver records the rationale band's cohesion rationale; the exception band needs a
50
- reasonable split attempt and full exception record. Size alone never requires
51
- user approval.
52
-
53
- ## Install from source
54
-
55
- Requirements: Bun >=1.3.14, Git, `gh`, the `gh stack` extension, and a running
56
- Orca with its runtime-owned guides. Filesystem access uses Bun's implementation
57
- of `node:fs` and `node:fs/promises`; there are no runtime dependencies.
3
+ Engineering workflows for AI agents, from an idea to a reviewed pull request.
4
+
5
+ Axstack is a set of skills for engineers who want agents to carry work forward
6
+ with less supervision, without giving up clear scope, independent review, or
7
+ control over what ships. Use it to plan a feature, implement an agreed task,
8
+ review a teammate's PR, or maintain your own PRs as feedback arrives.
9
+
10
+ Your chat stays in charge. Orca provides the worktrees, agent sessions, and
11
+ coordination; Axstack supplies the workflow and review rules. A small Bun CLI
12
+ installs the skills and checks prerequisites. There is no Axstack daemon,
13
+ scheduler, or runtime database to operate. Orca is the only supported runtime.
14
+
15
+ ## What you can do
16
+
17
+ | Need | Skill |
18
+ | --- | --- |
19
+ | Explore an idea and settle scope | `axstack-align` |
20
+ | Turn agreed scope into a specification | `axstack-spec` |
21
+ | Break a specification into executable tickets | `axstack-tickets` |
22
+ | Build an approved task with tests and independent review | `axstack-implement` |
23
+ | Review a pull request | `axstack-review` |
24
+ | Monitor or maintain an existing PR | `axstack-watch` |
25
+ | Diagnose a bug and establish a failing check | `axstack-debug` |
26
+ | Answer a bounded question with sources | `axstack-research` |
27
+ | Explain a system or identify improvements | `axstack-explain`, `axstack-improve` |
28
+ | Measure a run's outcomes and gaps | `axstack-audit` |
29
+ | Send an explicit message or authorized notification | `axstack-relay` |
30
+
31
+ Start at the phase you need. Small, bounded changes can begin with your request
32
+ or an existing issue; substantial work needs an approved spec and matching
33
+ tickets before implementation. Research, explanation, and peer review do not
34
+ require a new specification.
35
+
36
+ For a larger feature, the usual path is:
37
+
38
+ ```text
39
+ align → spec → tickets → implement → review → watch
40
+ ```
41
+
42
+ The implementation workflow includes the author–review–repair loop. You do not
43
+ need to manually coordinate every agent or repeat an approval that is still valid.
44
+
45
+ ## Quick start
46
+
47
+ You need Bun >=1.3.14, Git, the GitHub CLI (`gh`), the `gh stack` extension,
48
+ and a running Orca with its `orca-cli` and `orchestration` guides available.
49
+ The agents selected by your preset must also be available in Orca.
50
+
51
+ Install the CLI and skills for your harness. For example, for Codex:
58
52
 
59
53
  ```sh
60
- bun bin/axstack.js check --bundle . [--harness claude|codex|opencode|antigravity]
61
- bun bin/axstack.js install --bundle . --skills-dir <dir> --instructions <file> --preset mixed [--yes]
62
- bun bin/axstack.js install --bundle . --skills-dir <dir> --preset mixed
63
- bun bin/axstack.js uninstall --skills-dir <dir> --instructions <file>
54
+ bun add --global axstack
55
+ axstack check --harness codex
56
+ axstack install --harness codex --preset mixed --yes
57
+ ```
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
+
64
+ Then open an Orca chat and ask for the relevant skill:
65
+
66
+ ```text
67
+ $axstack-align Help me scope account recovery.
68
+ $axstack-implement Build the task we agreed on.
69
+ $axstack-review Review this pull request: <PR URL>
70
+ $axstack-watch Monitor this PR without making changes: <PR URL>
64
71
  ```
65
72
 
66
- Use `--harness claude|codex|opencode|antigravity` only for a verified default
67
- skill directory and rules file. `--claude-settings` and `--no-claude-settings` manage the existing
68
- Claude Code subagent default transaction; they do not configure Orca roles.
69
- See [installation details](docs/installation.md).
70
-
71
- `--instructions` manages one versioned Axstack block in `AGENTS.md`,
72
- `CLAUDE.md`, or `GEMINI.md`. Harness defaults resolve those files automatically. The block
73
- requires direct matching phase-skill invocation and requires every subagent, delegated
74
- worker, reviewer, and cross-harness dispatch to use visible Orca orchestration via the `orca` CLI
75
- rather than a harness-native subagent tool (e.g. Claude/Codex native subagents). OpenCode
76
- and Antigravity subagents run as Orca-supervised workers. Text and file
77
- mode outside the markers are preserved; edited, malformed, unowned, or unsafe
78
- targets are reported without normal-path adoption. Install exits nonzero when
79
- an instruction conflict is preserved, while clean and idempotent installs exit
80
- successfully.
81
-
82
- The public bundle preserves three canonical 24-role inputs:
73
+ Use `--harness claude`, `opencode`, or `antigravity` for another supported
74
+ installation target, or provide explicit skill and instruction paths.
75
+ Installation adds an owned instruction block and preserves unrelated content;
76
+ it does not enable automations or prove that every configured model is available.
77
+ See [installation](docs/installation.md) for source installs, custom paths,
78
+ upgrades, conflicts, and uninstalling.
79
+
80
+ ## How work stays controlled
81
+
82
+ - **One accountable driver, one writer per candidate.** Your current chat
83
+ coordinates work; separate worktrees keep PR jobs isolated.
84
+ - **Independent review.** Peer PRs receive two independent reviews; authored
85
+ changes receive a reviewer selected from the actual author's configured
86
+ pairing. Reviews apply to an exact revision, not just a branch name.
87
+ - **Visible agent work.** Delegation uses visible Orca orchestration via the `orca` CLI,
88
+ not harness-native subagent tools.
89
+ - **Explicit boundaries.** Agents work within agreed scope. Missing authority,
90
+ unavailable models, and serious risks are surfaced rather than silently
91
+ bypassed. The human merges by default.
92
+ - **Resumable progress.** Work retains ownership, decisions, and evidence so a
93
+ later session can reconcile what happened before continuing.
94
+
95
+ Choose an explicit role preset:
83
96
  [mixed](profiles/presets/mixed.json),
84
- [codex-only](profiles/presets/codex-only.json), and
85
- [claude-only](profiles/presets/claude-only.json). Each is exactly
86
- `{ "version": 1, "roles": [...] }`. Installation writes the selected snapshot
87
- to `<skills-dir>/axstack/roles.json` as
88
- `{ "version": 1, "preset": "<name>", "roles": [...] }` under normal ownership
89
- hashes. An edited installed role file is preserved.
90
-
91
- Mixed configures independent Astra and Fable advisers at high. Single-provider
92
- presets preserve both adviser IDs and mark the unavailable one with `model:
93
- null` inside that preset's provider bounds; installation remains ready, while
94
- Align and Spec hold because both receipts are required. The mixed
95
- `axstack-checker` and `axstack-research-web-google` roles launch Antigravity by
96
- agent ID with explicit `model: null`; the run records the model reported by the
97
- TUI. The single-provider presets configure the checker and record the Google
98
- research branch as intentionally absent. Preset changes affect new runs only. Stored model, effort, and permission
99
- fields are declared intent until actual Orca launch receipts establish effective
100
- behavior; installation never proves provider availability or permission parity.
101
- Subscription availability and quota never select a fallback model.
102
- End-to-end compatibility remains unverified without matching runtime receipts.
103
-
104
- ## Runtime evidence and holds
105
-
106
- Native Orca exercises have returned Codex, Claude, and OpenCode Muse Spark
107
- worker completions, same-terminal follow-up, separate worktree placement, settlement
108
- cleanup, `user_takeover` retention, and recovery from `consumer_fenced`. These are
109
- bounded runtime facts, not proof that every role or harness is compatible.
110
-
111
- Input acceptance is not agent readiness. A trust prompt was observed after an
112
- accepted launch, so startup recovery must inspect the existing attempt, never
113
- answer trust or permission prompts on the worker's behalf, and never create a
114
- duplicate writer. A `worker_done` advances work only when its Task and Dispatch
115
- match the active attempt and its revision evidence verifies.
116
-
117
- Two logical native PR-manager lanes run on staggered 15-minute schedules. Each
118
- pass uses a fresh finite session in a new isolated workspace. Review
119
- and watch each admit at most five bounded PR jobs;
120
- waiting PRs stay covered without reserving slots. Axstack adds no custom
121
- scheduler, queue engine, or decision interpreter. Sessions reconcile before
122
- admission across the whole lane, save durable continuity and decisions outside
123
- disposable workspaces, and retire only their verified pass workspace as the
124
- final action. Manual review/watch never inherits this cleanup lifecycle.
125
- Native fresh-session, overlapping-pass, recovery, and VPS resource behavior
126
- require a canary before activation.
127
-
128
- Mobile completion/reply behavior remains unverified. Structural checks and
129
- qualitative scenario evaluation are not live runtime proof.
130
-
131
- ## Historical migration boundary
132
-
133
- Older releases used Paseo for orchestration and could leave profile ownership
134
- provenance or retired skills behind. That state is historical and inert in the
135
- Orca runtime. Migration preserves user-edited and unknown assets and records
136
- legacy ownership without reading, writing, or deleting live host configuration.
137
- Use the explicit migration guidance in [installation](docs/installation.md);
138
- release installation, host cutover, and old-timer cleanup need separate
139
- authorization.
97
+ [codex-only](profiles/presets/codex-only.json), or
98
+ [claude-only](profiles/presets/claude-only.json).
99
+ Mixed supports the cross-provider implementation workflow. Single-provider
100
+ presets have workflow limitations; they are not automatic fallbacks when a
101
+ model is unavailable. See [workflow and routing details](docs/workflows.md).
102
+
103
+ ## Optional PR automation
104
+
105
+ Manual review and watch work independently of scheduled automation.
106
+ For recurring use, Axstack defines two native Orca manager lanes: one discovers
107
+ eligible peer-review requests, and the other monitors your open PRs and handles
108
+ authorized repair events.
109
+
110
+ The lanes use staggered 15-minute schedules. Each pass uses a fresh finite
111
+ session in an isolated workspace and admits at most five executing PR jobs per
112
+ lane, fewer under resource pressure. Waiting PRs remain tracked without consuming
113
+ execution slots, so a large open-PR backlog does not require an idle agent per PR.
114
+ Completed passes save continuity and retire their own verified resources.
115
+
116
+ Scheduling is opt-in and requires host-specific runtime validation before
117
+ activation. Installing Axstack does not turn it on. Repairs are limited to
118
+ explicitly allowed repositories; automation never merges for you.
119
+ See [PR-manager setup and safety](skills/axstack/references/automations.md).
120
+
121
+ ## Documentation
122
+
123
+ - [Installation and configuration](docs/installation.md)
124
+ - [Workflows, review policy, and model routing](docs/workflows.md)
125
+ - [PR scope and sizing](skills/axstack/references/pr-shape.md)
126
+ - [Releases](https://github.com/axatbhardwaj/axstack/releases)
140
127
 
141
128
  ## License
142
129
 
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/docs/workflows.md CHANGED
@@ -228,6 +228,8 @@ Predeclared scenario evaluation is qualitative behavior evidence, not determinis
228
228
  compatibility requires actual guide discovery, role/session evidence, worktree
229
229
  and Dispatch receipts, completion delivery, and cleanup as applicable. Mobile
230
230
  completion and reply behavior remain unverified.
231
+ End-to-end compatibility remains unverified for any route without matching
232
+ runtime receipts; evidence from one route does not establish support for all roles.
231
233
 
232
234
  ## Historical migration
233
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
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
+ }
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',