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 +119 -132
- package/bin/axstack.js +68 -4
- package/docs/installation.md +20 -1
- package/docs/workflows.md +2 -0
- 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/src/installer.js +172 -1
- package/src/locations.js +3 -3
package/README.md
CHANGED
|
@@ -1,142 +1,129 @@
|
|
|
1
1
|
# Axstack
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
The
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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),
|
|
85
|
-
[claude-only](profiles/presets/claude-only.json).
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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/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
|
@@ -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
|
+
}
|
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',
|