axstack 0.20.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.20.8",
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",
@@ -59,12 +59,44 @@ state and require exit proof for that exact terminal incarnation. A task
59
59
  terminal, manual chat, unexpected terminal, failed close, or uncertain exit
60
60
  remains protected.
61
61
 
62
- After receipt and continuity readback, if the only remaining Git dirt is the
63
- verified archived untracked evidence, compare its current bytes with the
64
- manifest again and unlink only those exact regular evidence files individually.
65
- Never remove tracked or unknown files, directories, or any path whose hash now
66
- differs. Re-read Git and native state; any remaining or uncertain dirt holds
67
- 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.
68
100
 
69
101
  Only then use the version-matched Orca guide's native worktree cleanup operation
70
102
  with the exact workspace identity. Never use shell recursive deletion and never
@@ -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,7 +69,7 @@ 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', 'run', 'task', '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}`);
@@ -76,6 +77,7 @@ function parseArgs(argv) {
76
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');
@@ -95,6 +97,13 @@ function parseArgs(argv) {
95
97
  }
96
98
  if (!/^[0-9a-f]{40}$/.test(values.head)) fail('head must be an exact 40-character lowercase SHA');
97
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
+ }
98
107
  values.files = [...new Set(values.files)].sort();
99
108
  for (const file of values.files) {
100
109
  const parts = file.split('/');
@@ -212,6 +221,203 @@ async function verifyArchive(archiveDir, identity, collected) {
212
221
  };
213
222
  }
214
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
+
215
421
  async function listArchiveFiles(root, prefix = '') {
216
422
  const files = [];
217
423
  const entries = await readdir(join(root, prefix), { withFileTypes: true });
@@ -238,7 +444,6 @@ async function main() {
238
444
  archiveToSource === '' || (!archiveToSource.startsWith('..') && !isAbsolute(archiveToSource))
239
445
  ) fail('source and archive roots must not contain each other');
240
446
 
241
- const collected = await collectSource(sourceRoot, args.files);
242
447
  const identity = args.pr
243
448
  ? { repo: args.repo, pr: Number(args.pr), head: args.head, dispatch: args.dispatch }
244
449
  : { repo: args.repo, run: args.run, task: args.task, head: args.head, dispatch: args.dispatch };
@@ -249,6 +454,12 @@ async function main() {
249
454
  const archiveDir = join(archiveRoot, repoSlug, ...identityParts, args.head, args.dispatch);
250
455
 
251
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);
252
463
  let current = archiveRoot;
253
464
  const generated = [repoSlug, ...identityParts, args.head];
254
465
  await ensurePrivateDir(current);
@@ -81,9 +81,15 @@ Treat these operations as separate decisions and receipts:
81
81
  3. **Worktree removal.** Re-read Git status, branch/upstream divergence,
82
82
  unpushed commits, forge merge/publication state, children, terminals, and
83
83
  archived evidence immediately before the native exact-workspace removal.
84
- Account for branch-deletion side effects explicitly, then re-list both native
85
- workspaces and Git refs. A failed hook or uncertain response preserves the
86
- resource; never force or substitute shell deletion.
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.
87
93
  4. **Chat archival.** Attempt it only if the version-matched runtime guide
88
94
  advertises a distinct supported operation and the scoped chat is eligible.
89
95
  Otherwise record chat archival as unsupported. Process exit, worker release,