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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.20.7",
3
+ "version": "0.20.9",
4
4
  "description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks Orca capabilities.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -7,7 +7,7 @@ native cleanup.
7
7
 
8
8
  ## Eligibility
9
9
 
10
- First prove the exact repository, PR, 40-character head SHA, Task/Dispatch,
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/Task/Dispatch/workspace/terminal
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, 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.
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; (5) mark the
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', 'pr', 'head', 'dispatch']) {
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
- if (!/^[1-9][0-9]*$/.test(values.pr)) fail('pr must be a positive integer');
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 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
- };
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 archiveDir = join(archiveRoot, repoSlug, `pr-${args.pr}`, args.head, args.dispatch);
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, `pr-${args.pr}`, args.head];
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.