axstack 0.20.7 → 0.20.9
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 +4 -0
- package/docs/workflows.md +7 -0
- package/package.json +1 -1
- package/skills/axstack/references/evidence-archive.md +45 -8
- package/skills/axstack/references/lifecycle.md +5 -2
- package/skills/axstack/references/routing.md +2 -0
- package/skills/axstack/scripts/archive-evidence.js +234 -12
- package/skills/axstack-cleanup/SKILL.md +108 -0
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ scheduler, or runtime database to operate. Orca is the only supported runtime.
|
|
|
26
26
|
| Answer a bounded question with sources | `axstack-research` |
|
|
27
27
|
| Explain a system or identify improvements | `axstack-explain`, `axstack-improve` |
|
|
28
28
|
| Measure a run's outcomes and gaps | `axstack-audit` |
|
|
29
|
+
| Retire eligible completed subagent resources | `axstack-cleanup` |
|
|
29
30
|
| Send an explicit message or authorized notification | `axstack-relay` |
|
|
30
31
|
|
|
31
32
|
Start at the phase you need. Small, bounded changes can begin with your request
|
|
@@ -91,6 +92,9 @@ upgrades, conflicts, and uninstalling.
|
|
|
91
92
|
bypassed. The human merges by default.
|
|
92
93
|
- **Resumable progress.** Work retains ownership, decisions, and evidence so a
|
|
93
94
|
later session can reconcile what happened before continuing.
|
|
95
|
+
- **Bounded cleanup.** The driver can retire proven completed subagent resources
|
|
96
|
+
inline or from an explicitly scoped backlog without touching active, manual,
|
|
97
|
+
uncertain, user-owned, dirty, unpushed, or useful unmerged work.
|
|
94
98
|
|
|
95
99
|
Choose an explicit role preset:
|
|
96
100
|
[mixed](profiles/presets/mixed.json),
|
package/docs/workflows.md
CHANGED
|
@@ -24,6 +24,9 @@ Direct routes need no spec ceremony:
|
|
|
24
24
|
- `axstack-debug` builds a red loop, diagnoses to root cause, escalates hard
|
|
25
25
|
bugs through adviser-directed investigator fan-out, and hands off a
|
|
26
26
|
classified repair without landing a change.
|
|
27
|
+
- `axstack-cleanup` runs inline in the driver after accepted worker, Task, or
|
|
28
|
+
Run completion, or against an explicitly bounded backlog. It dispatches no
|
|
29
|
+
cleanup worker and preserves protected or uncertain resources.
|
|
27
30
|
- Peer review uses the linked issue, PR description, and repository rules as
|
|
28
31
|
untrusted intent evidence.
|
|
29
32
|
- Existing-PR maintenance uses one accepted maintenance snapshot.
|
|
@@ -145,6 +148,10 @@ session and evidence remain valid.
|
|
|
145
148
|
lands by fast-forward `git push` after final readback.
|
|
146
149
|
- `axstack-audit` separates execution outcome, procedure, and measurement
|
|
147
150
|
coverage with evidenced denominators; it proposes but never self-edits.
|
|
151
|
+
- `axstack-cleanup` distinguishes settled-Dispatch release, exact unused-shell
|
|
152
|
+
close, evidence-safe native worktree removal and branch effects, and separate
|
|
153
|
+
chat archival when the discovered runtime actually supports it. Process exit
|
|
154
|
+
alone never promises that visible chat history disappeared.
|
|
148
155
|
|
|
149
156
|
One Orca execution host owns a run, one persistent owner owns each PR, and one
|
|
150
157
|
writer owns each candidate. Fanout has no fixed PR count; it follows real
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ native cleanup.
|
|
|
7
7
|
|
|
8
8
|
## Eligibility
|
|
9
9
|
|
|
10
|
-
First prove the exact repository, PR, 40-character head SHA,
|
|
10
|
+
First prove the exact repository, PR or Run/Task, 40-character head SHA, Dispatch,
|
|
11
11
|
workspace, terminal incarnation, automation ownership, descendant settlement,
|
|
12
12
|
and liveness from current native state. A manual chat, `user_takeover`, an
|
|
13
13
|
active or unknown task terminal, an unsettled descendant, unpushed commits,
|
|
@@ -34,6 +34,11 @@ bun scripts/archive-evidence.js \
|
|
|
34
34
|
--file <classified-relative-file> [--file <classified-relative-file> ...]
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
For a supervised resource without a PR identity, replace `--pr <number>` with
|
|
38
|
+
`--run <exact-run-id> --task <exact-task-id>`. The two identity forms are
|
|
39
|
+
mutually exclusive; never invent a PR number. Existing PR archives retain their
|
|
40
|
+
path, manifest bytes, and receipt interface.
|
|
41
|
+
|
|
37
42
|
The helper refuses path escapes, symlinks, non-private archive directories,
|
|
38
43
|
identity changes, and existing content that does not verify. It copies only the
|
|
39
44
|
listed regular files, writes them with private permissions, hashes their exact
|
|
@@ -41,7 +46,7 @@ bytes, and emits a JSON receipt containing the archive directory, manifest path,
|
|
|
41
46
|
manifest hash, and file count. Repeating the same command verifies the immutable
|
|
42
47
|
archive and returns the same receipt; it does not overwrite it.
|
|
43
48
|
|
|
44
|
-
Record the receipt plus the exact repo/PR/head/
|
|
49
|
+
Record the receipt plus the exact repo/PR or Run/Task/head/Dispatch/workspace/terminal
|
|
45
50
|
identities in durable lane continuity, then read the continuity and archive
|
|
46
51
|
manifest back before cleanup. If either readback differs or is unavailable,
|
|
47
52
|
preserve the worktree.
|
|
@@ -54,12 +59,44 @@ state and require exit proof for that exact terminal incarnation. A task
|
|
|
54
59
|
terminal, manual chat, unexpected terminal, failed close, or uncertain exit
|
|
55
60
|
remains protected.
|
|
56
61
|
|
|
57
|
-
After receipt and continuity readback,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
After receipt and continuity readback, invoke the same installed helper with
|
|
63
|
+
the same identity and complete `--file` set, plus the recorded manifest hash:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
bun scripts/archive-evidence.js \
|
|
67
|
+
--source-root <absolute-worktree-or-evidence-root> \
|
|
68
|
+
--archive-root <absolute-private-archive-root> \
|
|
69
|
+
--repo <owner/repository> --pr <number> --head <40-character-sha> \
|
|
70
|
+
--dispatch <exact-dispatch-id> \
|
|
71
|
+
--file <classified-relative-file> [--file <classified-relative-file> ...] \
|
|
72
|
+
--operation retire --manifest-hash <recorded-64-character-sha256>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use the corresponding `--run` and `--task` identity for a non-PR archive. The
|
|
76
|
+
retirement operation independently verifies the immutable private archive, its
|
|
77
|
+
exact identity and complete file set, the caller-recorded manifest hash, the
|
|
78
|
+
exact Git top-level and HEAD, and every remaining source file. It refuses
|
|
79
|
+
tracked, staged, or unclassified dirt; changed bytes; unsafe or overlapping
|
|
80
|
+
roots; symlinks; hard links; and non-regular files. It applies exact unlinks only
|
|
81
|
+
to matching listed files and never removes directories.
|
|
82
|
+
|
|
83
|
+
Record the retirement receipt's `removed`, `alreadyAbsent`, and `pending` file
|
|
84
|
+
sets. A repeat invocation reconciles already absent files without changing the
|
|
85
|
+
manifest. A partial or failed invocation preserves the archive; resolve its
|
|
86
|
+
exact hold and retry the same operation until `pending` is empty. Never replace
|
|
87
|
+
this operation with a shell loop, broad deletion, force, or a waiver.
|
|
88
|
+
|
|
89
|
+
Re-read Git and native state after successful retirement. Any remaining or
|
|
90
|
+
uncertain dirt holds worktree removal. Settlement, liveness/no-writer proof,
|
|
91
|
+
useful-work and publication checks, evidence classification, and removal
|
|
92
|
+
authority remain driver decisions; archive or retirement success proves none of
|
|
93
|
+
them.
|
|
94
|
+
|
|
95
|
+
Before native worktree removal, verify the effective **Archive Script**
|
|
96
|
+
provenance. An unknown hook or a required hook whose provenance is not trusted
|
|
97
|
+
holds removal. Record its native outcome as exactly `unconfigured`, `passed`,
|
|
98
|
+
`failed`, or `unknown`; only `unconfigured` or a trusted `passed` outcome may
|
|
99
|
+
advance, while `failed` and `unknown` preserve the resource.
|
|
63
100
|
|
|
64
101
|
Only then use the version-matched Orca guide's native worktree cleanup operation
|
|
65
102
|
with the exact workspace identity. Never use shell recursive deletion and never
|
|
@@ -87,7 +87,9 @@ are acknowledged with no user-facing text. Process each whole delivery before
|
|
|
87
87
|
acknowledgment and validate its Task, Dispatch, sender, authority, revisions,
|
|
88
88
|
and receipts before advancing the run record. Duplicate deliveries are
|
|
89
89
|
deduplicated by runtime identity. Healthy unchanged observations produce no
|
|
90
|
-
user-facing update.
|
|
90
|
+
user-facing update. After accepting worker, Task, or Run completion, the driver
|
|
91
|
+
invokes [axstack-cleanup](../../axstack-cleanup/SKILL.md) inline; it never
|
|
92
|
+
dispatches cleanup work.
|
|
91
93
|
|
|
92
94
|
Detect completed-but-unadvanced work, failed sessions, unresolved launch
|
|
93
95
|
receipts, and stalls through the version-matched orchestration guide. Never
|
|
@@ -133,7 +135,8 @@ terminal; (2) compact record with counts and denominators—user
|
|
|
133
135
|
interventions/deviations from plan/repairs; (3) `axstack-auditor`: settle
|
|
134
136
|
non-zero/requested, else `counts zero`; an unavailable auditor leaves close-out
|
|
135
137
|
pending, never skipped silently; (4) release merged run worktrees and branches;
|
|
136
|
-
close selected external-tracker tickets when applicable;
|
|
138
|
+
use `axstack-cleanup` and close selected external-tracker tickets when applicable;
|
|
139
|
+
(5) mark the
|
|
137
140
|
[Run record](run-record.md) `Archived`. `Archived`—one each:
|
|
138
141
|
settlement receipt; compact record path; auditor decision plus settlement
|
|
139
142
|
receipt or `counts zero`; release and ticket receipts; archive timestamp.
|
|
@@ -69,6 +69,8 @@ step (3) for user routing, with no substitution or same-provider review.
|
|
|
69
69
|
- Codebase-quality or refactor discovery -> `axstack-improve`: inspect bounded
|
|
70
70
|
scope, rank evidenced candidates, report only; no spec, tickets, or source
|
|
71
71
|
edits.
|
|
72
|
+
- Accepted worker/Task/Run completion or bounded backlog request -> invoke
|
|
73
|
+
`axstack-cleanup` inline in the driver; never dispatch it.
|
|
72
74
|
- Preparation completion, watch expiry, resume, or reconciliation -> the
|
|
73
75
|
[lifecycle](lifecycle.md#native-handoff-and-resume): reconcile the run
|
|
74
76
|
record, keep its owner, launch no native handoff.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readFile,
|
|
8
8
|
rename,
|
|
9
9
|
rm,
|
|
10
|
+
unlink,
|
|
10
11
|
writeFile,
|
|
11
12
|
} from 'node:fs/promises';
|
|
12
13
|
|
|
@@ -68,22 +69,41 @@ function parseArgs(argv) {
|
|
|
68
69
|
if (!flag?.startsWith('--') || value === undefined) fail(`invalid argument near ${flag ?? '(end)'}`);
|
|
69
70
|
const key = flag.slice(2);
|
|
70
71
|
if (key === 'file') values.files.push(value);
|
|
71
|
-
else if (['source-root', 'archive-root', 'repo', 'pr', 'head', 'dispatch'].includes(key)) {
|
|
72
|
+
else if (['source-root', 'archive-root', 'repo', 'pr', 'run', 'task', 'head', 'dispatch', 'operation', 'manifest-hash'].includes(key)) {
|
|
72
73
|
if (values[key] !== undefined) fail(`duplicate --${key}`);
|
|
73
74
|
values[key] = value;
|
|
74
75
|
} else fail(`unknown argument: ${flag}`);
|
|
75
76
|
}
|
|
76
|
-
for (const key of ['source-root', 'archive-root', 'repo', '
|
|
77
|
+
for (const key of ['source-root', 'archive-root', 'repo', 'head', 'dispatch']) {
|
|
77
78
|
if (!values[key]) fail(`missing --${key}`);
|
|
78
79
|
}
|
|
80
|
+
values.operation ??= 'archive';
|
|
79
81
|
if (values.files.length === 0) fail('at least one --file is required');
|
|
80
82
|
if (!isAbsolute(values['source-root']) || !isAbsolute(values['archive-root'])) {
|
|
81
83
|
fail('source and archive roots must be absolute');
|
|
82
84
|
}
|
|
83
85
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(values.repo)) fail('repo must be owner/name');
|
|
84
|
-
|
|
86
|
+
const hasPr = values.pr !== undefined;
|
|
87
|
+
const hasRun = values.run !== undefined;
|
|
88
|
+
const hasTask = values.task !== undefined;
|
|
89
|
+
if (hasPr === hasRun || hasRun !== hasTask) {
|
|
90
|
+
fail('identity requires either --pr or both --run and --task, mutually exclusive');
|
|
91
|
+
}
|
|
92
|
+
if (hasPr && !/^[1-9][0-9]*$/.test(values.pr)) fail('pr must be a positive integer');
|
|
93
|
+
for (const key of ['run', 'task']) {
|
|
94
|
+
if (values[key] !== undefined && !/^[A-Za-z0-9_-]+$/.test(values[key])) {
|
|
95
|
+
fail(`${key} contains unsafe characters`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
85
98
|
if (!/^[0-9a-f]{40}$/.test(values.head)) fail('head must be an exact 40-character lowercase SHA');
|
|
86
99
|
if (!/^[A-Za-z0-9_-]+$/.test(values.dispatch)) fail('dispatch contains unsafe characters');
|
|
100
|
+
if (!['archive', 'retire'].includes(values.operation)) fail('operation must be archive or retire');
|
|
101
|
+
if (values.operation === 'retire' && !/^[0-9a-f]{64}$/.test(values['manifest-hash'] ?? '')) {
|
|
102
|
+
fail('retirement requires an exact lowercase --manifest-hash');
|
|
103
|
+
}
|
|
104
|
+
if (values.operation === 'archive' && values['manifest-hash'] !== undefined) {
|
|
105
|
+
fail('--manifest-hash applies only to retirement');
|
|
106
|
+
}
|
|
87
107
|
values.files = [...new Set(values.files)].sort();
|
|
88
108
|
for (const file of values.files) {
|
|
89
109
|
const parts = file.split('/');
|
|
@@ -201,6 +221,203 @@ async function verifyArchive(archiveDir, identity, collected) {
|
|
|
201
221
|
};
|
|
202
222
|
}
|
|
203
223
|
|
|
224
|
+
function sameJson(left, right) {
|
|
225
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function verifyRetirementArchive(archiveRoot, archiveDir, identity, files, manifestHash) {
|
|
229
|
+
const archiveRel = relative(archiveRoot, archiveDir);
|
|
230
|
+
let current = archiveRoot;
|
|
231
|
+
for (const part of ['', ...archiveRel.split('/')]) {
|
|
232
|
+
if (part) current = join(current, part);
|
|
233
|
+
const st = await assertRealPath(current, 'directory');
|
|
234
|
+
if ((st.mode & 0o077) !== 0) fail(`archive permissions are not private: ${current}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const manifestPath = join(archiveDir, 'manifest.json');
|
|
238
|
+
const manifestStat = await assertRealPath(manifestPath, 'file');
|
|
239
|
+
if (manifestStat.nlink !== 1) fail(`refusing hard-linked archive file: ${manifestPath}`);
|
|
240
|
+
if ((manifestStat.mode & 0o077) !== 0) fail(`archive manifest permissions are not private: ${manifestPath}`);
|
|
241
|
+
const manifestBytes = await readFile(manifestPath);
|
|
242
|
+
if (sha256(manifestBytes) !== manifestHash) fail('recorded manifest hash mismatch');
|
|
243
|
+
|
|
244
|
+
let manifest;
|
|
245
|
+
try {
|
|
246
|
+
manifest = JSON.parse(manifestBytes.toString());
|
|
247
|
+
} catch {
|
|
248
|
+
fail(`invalid archive manifest: ${manifestPath}`);
|
|
249
|
+
}
|
|
250
|
+
if (manifest.version !== 1 || !sameJson(manifest.identity, identity)) {
|
|
251
|
+
fail(`archive manifest identity mismatch: ${manifestPath}`);
|
|
252
|
+
}
|
|
253
|
+
const manifestFiles = Object.keys(manifest.files ?? {}).sort();
|
|
254
|
+
if (!sameJson(manifestFiles, files)) fail(`archive manifest file set mismatch: ${manifestPath}`);
|
|
255
|
+
if (!manifestBytes.equals(Buffer.from(JSON.stringify(manifest, null, 2) + '\n'))) {
|
|
256
|
+
fail(`archive manifest bytes are not canonical: ${manifestPath}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const filesRoot = join(archiveDir, 'files');
|
|
260
|
+
const filesRootStat = await assertRealPath(filesRoot, 'directory');
|
|
261
|
+
if ((filesRootStat.mode & 0o077) !== 0) fail(`archive permissions are not private: ${filesRoot}`);
|
|
262
|
+
for (const rel of files) {
|
|
263
|
+
const expected = manifest.files[rel];
|
|
264
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected.sha256) || !Number.isSafeInteger(expected.size) || expected.size < 0) {
|
|
265
|
+
fail(`invalid archive manifest entry: ${rel}`);
|
|
266
|
+
}
|
|
267
|
+
await assertNoSymlinkComponents(filesRoot, rel);
|
|
268
|
+
const archived = join(filesRoot, rel);
|
|
269
|
+
const st = await assertRealPath(archived, 'file');
|
|
270
|
+
if (st.nlink !== 1) fail(`refusing hard-linked archive file: ${archived}`);
|
|
271
|
+
if ((st.mode & 0o077) !== 0) fail(`archived evidence permissions are not private: ${archived}`);
|
|
272
|
+
const bytes = await readFile(archived);
|
|
273
|
+
if (bytes.length !== expected.size || sha256(bytes) !== expected.sha256) {
|
|
274
|
+
fail(`archived evidence hash mismatch: ${rel}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const actualFiles = await listArchiveFiles(archiveDir);
|
|
278
|
+
const expectedFiles = ['manifest.json', ...files.map((rel) => `files/${rel}`)].sort();
|
|
279
|
+
if (!sameJson(actualFiles, expectedFiles)) fail(`archive contains unexpected or missing files: ${archiveDir}`);
|
|
280
|
+
return { archiveDir, manifestPath, manifestHash, files: files.length, manifest };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function gitOutput(sourceRoot, args) {
|
|
284
|
+
const result = Bun.spawnSync(['git', '-C', sourceRoot, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
285
|
+
if (result.exitCode !== 0) {
|
|
286
|
+
fail(`git ${args.join(' ')} failed: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
|
|
287
|
+
}
|
|
288
|
+
return result.stdout.toString();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function inspectSource(sourceRoot, rel) {
|
|
292
|
+
let current = sourceRoot;
|
|
293
|
+
const parts = rel.split('/');
|
|
294
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
295
|
+
current = join(current, parts[index]);
|
|
296
|
+
const st = await lstat(current, { bigint: true }).catch((err) => {
|
|
297
|
+
if (err?.code === 'ENOENT') return null;
|
|
298
|
+
throw err;
|
|
299
|
+
});
|
|
300
|
+
if (!st) return null;
|
|
301
|
+
if (st.isSymbolicLink()) fail(`refusing symlink: ${current}`);
|
|
302
|
+
if (index < parts.length - 1 && !st.isDirectory()) fail(`not a directory: ${current}`);
|
|
303
|
+
if (index === parts.length - 1) {
|
|
304
|
+
if (!st.isFile()) fail(`not a regular file: ${current}`);
|
|
305
|
+
if (st.nlink !== 1n) fail(`refusing hard-linked source file: ${current}`);
|
|
306
|
+
const bytes = await readFile(current);
|
|
307
|
+
const after = await lstat(current, { bigint: true }).catch((err) => {
|
|
308
|
+
if (err?.code === 'ENOENT') fail(`source changed during verification: ${rel}`);
|
|
309
|
+
throw err;
|
|
310
|
+
});
|
|
311
|
+
for (const key of ['dev', 'ino', 'size', 'mtimeNs', 'nlink']) {
|
|
312
|
+
if (st[key] !== after[key]) fail(`source changed during verification: ${rel}`);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
bytes,
|
|
316
|
+
sha256: sha256(bytes),
|
|
317
|
+
size: bytes.length,
|
|
318
|
+
fingerprint: Object.fromEntries(
|
|
319
|
+
['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs', 'nlink'].map((key) => [key, after[key]]),
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sameSource(left, right) {
|
|
327
|
+
return left.size === right.size &&
|
|
328
|
+
left.sha256 === right.sha256 &&
|
|
329
|
+
Object.keys(left.fingerprint).every((key) => left.fingerprint[key] === right.fingerprint[key]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function verifyRetirementSource(sourceRoot, head, files, manifest) {
|
|
333
|
+
await assertSafeAncestors(sourceRoot);
|
|
334
|
+
await assertRealPath(sourceRoot, 'directory');
|
|
335
|
+
const topLevel = resolve(gitOutput(sourceRoot, ['rev-parse', '--show-toplevel']).trim());
|
|
336
|
+
if (topLevel !== sourceRoot) fail(`source root is not the exact Git top-level: ${sourceRoot}`);
|
|
337
|
+
const actualHead = gitOutput(sourceRoot, ['rev-parse', 'HEAD']).trim();
|
|
338
|
+
if (actualHead !== head) fail(`Git HEAD mismatch: expected ${head}, found ${actualHead}`);
|
|
339
|
+
|
|
340
|
+
const states = {};
|
|
341
|
+
for (const rel of files) {
|
|
342
|
+
const state = await inspectSource(sourceRoot, rel);
|
|
343
|
+
if (state && (state.size !== manifest.files[rel].size || state.sha256 !== manifest.files[rel].sha256)) {
|
|
344
|
+
fail(`source evidence hash mismatch: ${rel}`);
|
|
345
|
+
}
|
|
346
|
+
states[rel] = state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const expectedUntracked = new Set(files.filter((rel) => states[rel]));
|
|
350
|
+
const statusEntries = gitOutput(sourceRoot, [
|
|
351
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=no',
|
|
352
|
+
]).split('\0').filter(Boolean);
|
|
353
|
+
for (const entry of statusEntries) {
|
|
354
|
+
const code = entry.slice(0, 2);
|
|
355
|
+
const rel = entry.slice(3);
|
|
356
|
+
if (code !== '??' || !expectedUntracked.delete(rel)) {
|
|
357
|
+
fail(`tracked, staged, or unclassified Git dirt: ${rel || '(unknown)'}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (expectedUntracked.size > 0) {
|
|
361
|
+
fail(`source evidence is not classified as untracked: ${[...expectedUntracked].sort()[0]}`);
|
|
362
|
+
}
|
|
363
|
+
return states;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function retireEvidence(args, sourceRoot, archiveRoot, archiveDir, identity) {
|
|
367
|
+
const archive = await verifyRetirementArchive(
|
|
368
|
+
archiveRoot, archiveDir, identity, args.files, args['manifest-hash'],
|
|
369
|
+
);
|
|
370
|
+
const states = await verifyRetirementSource(sourceRoot, args.head, args.files, archive.manifest);
|
|
371
|
+
const removed = [];
|
|
372
|
+
const alreadyAbsent = args.files.filter((rel) => !states[rel]);
|
|
373
|
+
|
|
374
|
+
for (const rel of args.files) {
|
|
375
|
+
if (!states[rel]) continue;
|
|
376
|
+
const current = await inspectSource(sourceRoot, rel);
|
|
377
|
+
if (!current || !sameSource(current, states[rel])) fail(`source changed before retirement: ${rel}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (let index = 0; index < args.files.length; index += 1) {
|
|
381
|
+
const rel = args.files[index];
|
|
382
|
+
if (!states[rel]) continue;
|
|
383
|
+
const current = await inspectSource(sourceRoot, rel);
|
|
384
|
+
if (!current || !sameSource(current, states[rel])) {
|
|
385
|
+
fail(`source changed before retirement: ${rel}`);
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await unlink(join(sourceRoot, rel));
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const pending = args.files.slice(index).filter((file) => states[file]);
|
|
391
|
+
console.log(JSON.stringify({
|
|
392
|
+
status: 'partial',
|
|
393
|
+
archiveDir: archive.archiveDir,
|
|
394
|
+
manifestPath: archive.manifestPath,
|
|
395
|
+
manifestHash: archive.manifestHash,
|
|
396
|
+
files: archive.files,
|
|
397
|
+
removed,
|
|
398
|
+
alreadyAbsent,
|
|
399
|
+
pending,
|
|
400
|
+
}));
|
|
401
|
+
fail(`retirement stopped at ${rel}: ${err.message}`);
|
|
402
|
+
}
|
|
403
|
+
if (await lstat(join(sourceRoot, rel)).catch((err) => err?.code === 'ENOENT' ? null : Promise.reject(err))) {
|
|
404
|
+
fail(`source still exists after unlink: ${rel}`);
|
|
405
|
+
}
|
|
406
|
+
removed.push(rel);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log(JSON.stringify({
|
|
410
|
+
status: 'retired',
|
|
411
|
+
archiveDir: archive.archiveDir,
|
|
412
|
+
manifestPath: archive.manifestPath,
|
|
413
|
+
manifestHash: archive.manifestHash,
|
|
414
|
+
files: archive.files,
|
|
415
|
+
removed,
|
|
416
|
+
alreadyAbsent,
|
|
417
|
+
pending: [],
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
204
421
|
async function listArchiveFiles(root, prefix = '') {
|
|
205
422
|
const files = [];
|
|
206
423
|
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
@@ -227,19 +444,24 @@ async function main() {
|
|
|
227
444
|
archiveToSource === '' || (!archiveToSource.startsWith('..') && !isAbsolute(archiveToSource))
|
|
228
445
|
) fail('source and archive roots must not contain each other');
|
|
229
446
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
repo: args.repo,
|
|
233
|
-
pr: Number(args.pr),
|
|
234
|
-
head: args.head,
|
|
235
|
-
dispatch: args.dispatch,
|
|
236
|
-
};
|
|
447
|
+
const identity = args.pr
|
|
448
|
+
? { repo: args.repo, pr: Number(args.pr), head: args.head, dispatch: args.dispatch }
|
|
449
|
+
: { repo: args.repo, run: args.run, task: args.task, head: args.head, dispatch: args.dispatch };
|
|
237
450
|
const repoSlug = args.repo.replace('/', '--');
|
|
238
|
-
const
|
|
451
|
+
const identityParts = args.pr
|
|
452
|
+
? [`pr-${args.pr}`]
|
|
453
|
+
: [`run-${args.run}`, `task-${args.task}`];
|
|
454
|
+
const archiveDir = join(archiveRoot, repoSlug, ...identityParts, args.head, args.dispatch);
|
|
239
455
|
|
|
240
456
|
await assertSafeAncestors(archiveRoot);
|
|
457
|
+
if (args.operation === 'retire') {
|
|
458
|
+
await retireEvidence(args, sourceRoot, archiveRoot, archiveDir, identity);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const collected = await collectSource(sourceRoot, args.files);
|
|
241
463
|
let current = archiveRoot;
|
|
242
|
-
const generated = [repoSlug,
|
|
464
|
+
const generated = [repoSlug, ...identityParts, args.head];
|
|
243
465
|
await ensurePrivateDir(current);
|
|
244
466
|
for (const part of generated) {
|
|
245
467
|
current = join(current, part);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: axstack-cleanup
|
|
3
|
+
description: When completed Orca subagent resources need bounded retirement, use axstack-cleanup after accepted settlement or for an explicitly scoped backlog.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cleanup
|
|
7
|
+
|
|
8
|
+
Run cleanup inline in the driver after accepting a worker, Task, or Run
|
|
9
|
+
completion, or for the exact backlog scope the user named. This skill never
|
|
10
|
+
dispatches a cleanup worker and never retires its current driver session.
|
|
11
|
+
|
|
12
|
+
Before any runtime action, load and follow:
|
|
13
|
+
|
|
14
|
+
- [Standing contracts](../axstack/references/contracts.md)
|
|
15
|
+
- [Lifecycle and receipts](../axstack/references/lifecycle.md)
|
|
16
|
+
- [Shared routing](../axstack/references/routing.md)
|
|
17
|
+
- [Orca runtime boundary](../axstack/references/orca-runtime.md)
|
|
18
|
+
- [Private evidence archive](../axstack/references/evidence-archive.md) when
|
|
19
|
+
evidence is the last removable-worktree blocker
|
|
20
|
+
|
|
21
|
+
Use the runtime-discovered Orca guides for inventory, release, terminal close,
|
|
22
|
+
and worktree removal. Do not embed or improvise a competing command protocol.
|
|
23
|
+
|
|
24
|
+
## Authority and scope
|
|
25
|
+
|
|
26
|
+
Inline cleanup may consider only resources owned by the accepted completion it
|
|
27
|
+
is processing. Backlog cleanup requires an explicit bounded selector such as a
|
|
28
|
+
Run, Task set, workspace set, repository, or named age window; age narrows an
|
|
29
|
+
inventory but never establishes eligibility. A partial inventory holds only the
|
|
30
|
+
resource whose identity or state is incomplete while other independently proven
|
|
31
|
+
resources may proceed.
|
|
32
|
+
|
|
33
|
+
Never clean a manual chat, the current driver, `user_takeover`, an active or
|
|
34
|
+
unknown worker, an unsettled descendant, or a resource with ambiguous ownership.
|
|
35
|
+
Preserve dirty or unknown files, unpushed commits, unmerged useful work,
|
|
36
|
+
ambiguous publication, and evidence that has not been durably preserved. Do not
|
|
37
|
+
force, bulk-clean, override a hook failure, edit a runtime database, or add a
|
|
38
|
+
scheduler, daemon, or state machine.
|
|
39
|
+
|
|
40
|
+
## Reconcile each candidate
|
|
41
|
+
|
|
42
|
+
Take a fresh native inventory and bind every candidate to its exact Run, Task,
|
|
43
|
+
Dispatch, terminal incarnation, workspace, repository, branch, and current
|
|
44
|
+
liveness. Read current Git and forge state rather than trusting age, names, or a
|
|
45
|
+
prior receipt. Reconcile an existing cleanup claim before retrying so repeated
|
|
46
|
+
invocations converge instead of duplicating mutations.
|
|
47
|
+
|
|
48
|
+
Retire descendants before parents. A candidate is eligible only when all owned
|
|
49
|
+
Dispatches are accepted as settled, no descendant remains unsettled, native
|
|
50
|
+
liveness is positively known where required, and every preservation guard is
|
|
51
|
+
cleared. Record one decision per resource; uncertainty about one candidate does
|
|
52
|
+
not authorize or block unrelated candidates.
|
|
53
|
+
|
|
54
|
+
## Preserve evidence first
|
|
55
|
+
|
|
56
|
+
Classify exact evidence files individually. Save the compact cleanup decision
|
|
57
|
+
and identities in the private run record or another configured durable private
|
|
58
|
+
location outside disposable worktrees. When the evidence archive applies, use
|
|
59
|
+
its helper with either the existing PR identity or the non-PR Run and Task
|
|
60
|
+
identity; never invent a PR number. Read back both the durable record and the
|
|
61
|
+
archive manifest, including hashes and exact identities, before removing any
|
|
62
|
+
source copy or workspace.
|
|
63
|
+
|
|
64
|
+
Archive success proves only preservation of the listed bytes. It does not prove
|
|
65
|
+
settlement, exit, ownership, a clean worktree, publication, or removal safety.
|
|
66
|
+
|
|
67
|
+
## Apply distinct native operations
|
|
68
|
+
|
|
69
|
+
Treat these operations as separate decisions and receipts:
|
|
70
|
+
|
|
71
|
+
1. **Worker release.** After the matching completion is accepted, use the
|
|
72
|
+
runtime guide's settled-Dispatch release operation. Release is not
|
|
73
|
+
cancellation, terminal-close proof, worktree removal, or chat archival.
|
|
74
|
+
Once required output is captured, a dirty or useful unmerged worktree does
|
|
75
|
+
not block release of its accepted settled worker; retain the worktree under
|
|
76
|
+
its own classification and receipt.
|
|
77
|
+
2. **Unused shell close.** Close only a positively identified unused setup
|
|
78
|
+
shell with the guide's exact-terminal operation. Re-list and require exit for
|
|
79
|
+
that same terminal incarnation. Never close a worker, manual, unexpected, or
|
|
80
|
+
current-driver terminal through this path.
|
|
81
|
+
3. **Worktree removal.** Re-read Git status, branch/upstream divergence,
|
|
82
|
+
unpushed commits, forge merge/publication state, children, terminals, and
|
|
83
|
+
archived evidence immediately before the native exact-workspace removal.
|
|
84
|
+
When archived evidence is the last dirt, use the evidence archive helper's
|
|
85
|
+
manifest-bound retirement operation and require an empty pending set; never
|
|
86
|
+
unlink through prose or a shell loop. Verify the effective Archive Script
|
|
87
|
+
provenance before native removal: an unknown or required-but-untrusted hook
|
|
88
|
+
holds. Record its native outcome as `unconfigured`, `passed`, `failed`, or
|
|
89
|
+
`unknown`; only `unconfigured` or trusted `passed` may advance. Account for
|
|
90
|
+
branch-deletion side effects explicitly, then re-list both native workspaces
|
|
91
|
+
and Git refs. A failed or unknown hook outcome or uncertain response preserves
|
|
92
|
+
the resource; never force or substitute shell deletion.
|
|
93
|
+
4. **Chat archival.** Attempt it only if the version-matched runtime guide
|
|
94
|
+
advertises a distinct supported operation and the scoped chat is eligible.
|
|
95
|
+
Otherwise record chat archival as unsupported. Process exit, worker release,
|
|
96
|
+
terminal close, and worktree removal do not prove UI history disappeared.
|
|
97
|
+
|
|
98
|
+
Do not self-close or self-remove. Return control to the driver after recording
|
|
99
|
+
receipts and holds; the owner decides when its own Run may archive.
|
|
100
|
+
|
|
101
|
+
## Receipt
|
|
102
|
+
|
|
103
|
+
Report the scope and inventory denominator, then for each candidate record its
|
|
104
|
+
exact identity, classification (`removed`, `retained`, `held`, or `unsupported`),
|
|
105
|
+
the fresh evidence used, native receipt and readback, and any resume condition.
|
|
106
|
+
Keep settlement, worker release, terminal exit, worktree/branch effects,
|
|
107
|
+
evidence preservation, and chat archival as separate fields. An idempotent retry
|
|
108
|
+
reconciles these receipts and performs only still-pending eligible operations.
|