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 +5 -0
- package/bin/axstack.js +68 -4
- package/docs/installation.md +20 -1
- package/package.json +1 -1
- package/skills/axstack/references/automations.md +24 -6
- package/skills/axstack/references/evidence-archive.md +68 -0
- package/skills/axstack/scripts/archive-evidence.js +298 -0
- package/skills/axstack-audit/SKILL.md +36 -3
- package/skills/axstack-audit/references/record.md +10 -2
- package/src/installer.js +172 -1
- package/src/locations.js +3 -3
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
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
return join(
|
|
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') {
|
package/docs/installation.md
CHANGED
|
@@ -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 |
|
|
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
|
@@ -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
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
28
|
-
|
|
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
|
|
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: '
|
|
20
|
+
skillsDir: '~/.agents/skills',
|
|
21
21
|
discovery: 'docs',
|
|
22
|
-
source: 'https://
|
|
22
|
+
source: 'https://learn.chatgpt.com/docs/build-skills',
|
|
23
23
|
notes:
|
|
24
|
-
'
|
|
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',
|