axstack 0.23.0 → 0.24.1
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 +3 -0
- package/bin/axstack.js +18 -3
- package/docs/installation.md +36 -5
- package/docs/workflows.md +5 -0
- package/package.json +1 -1
- package/skills/axstack/references/automations.md +45 -9
- package/skills/axstack/references/ui-verification.md +6 -0
- package/skills/axstack-diagram/SKILL.md +25 -0
- package/skills/axstack-diagram/references/archify.md +76 -0
- package/skills/axstack-diagram/references/fidelity.md +25 -0
- package/skills/axstack-explain/SKILL.md +15 -4
- package/skills/axstack-explain/references/visual-qa.md +3 -0
- package/src/archify-pin.js +5 -0
- package/src/archify.js +149 -0
- package/src/capabilities.js +30 -0
- package/src/installer.js +82 -0
- package/src/manifest.js +9 -0
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@ coordination. You can start at the phase you need.
|
|
|
29
29
|
| Operate | [axstack-relay](skills/axstack-relay/SKILL.md) | Send an explicit message or authorized notification. |
|
|
30
30
|
| Understand | [axstack-research](skills/axstack-research/SKILL.md) | Answer one bounded question with sources. |
|
|
31
31
|
| Understand | [axstack-explain](skills/axstack-explain/SKILL.md) | Explain a system and separate known behavior from gaps. |
|
|
32
|
+
| Understand | [axstack-diagram](skills/axstack-diagram/SKILL.md) | Draw Mermaid diagrams or verified interactive archify viewers. |
|
|
33
|
+
|
|
34
|
+
Interactive viewers use [archify](https://github.com/tt-a1i/archify) (MIT).
|
|
32
35
|
|
|
33
36
|
Small, bounded changes can begin with your request or an existing issue;
|
|
34
37
|
substantial work needs an approved spec and matching tickets before
|
package/bin/axstack.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
uninstallBundle,
|
|
14
14
|
validateBundle,
|
|
15
15
|
} from '../src/installer.js';
|
|
16
|
-
import { BUN_FLOOR, checkCapabilities, meetsFloor, runRealCheck } from '../src/capabilities.js';
|
|
16
|
+
import { BUN_FLOOR, checkArchify, checkCapabilities, meetsFloor, runRealCheck } from '../src/capabilities.js';
|
|
17
17
|
import { findLegacyRoutingLines } from '../src/instructions.js';
|
|
18
18
|
import { harnessLocations } from '../src/locations.js';
|
|
19
19
|
|
|
@@ -43,7 +43,7 @@ function packageVersion() {
|
|
|
43
43
|
const HELP = `axstack — Axstack setup CLI (installation bookkeeping only)
|
|
44
44
|
|
|
45
45
|
Usage:
|
|
46
|
-
axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-dir <dir> [--instructions <file>] [--claude-settings <file>|--no-claude-settings] [--harness <name>] [--force] [--yes]
|
|
46
|
+
axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-dir <dir> [--tools-dir <dir>] [--instructions <file>] [--claude-settings <file>|--no-claude-settings] [--harness <name>] [--force] [--yes]
|
|
47
47
|
axstack check [--bundle <dir>] [--instructions <file>] [--skills-dir <dir>|--harness <name>]
|
|
48
48
|
axstack uninstall --skills-dir <dir> [--instructions <file>] [--claude-settings <file>|--no-claude-settings] [--harness <name>] [--force] [--yes]
|
|
49
49
|
axstack --help | --version
|
|
@@ -62,6 +62,8 @@ Flags:
|
|
|
62
62
|
Aliases: codex = codex-only; claude = claude-only.
|
|
63
63
|
--skills-dir <dir> Explicit install target. Overrides harness skill defaults
|
|
64
64
|
and automatic legacy Codex skill retirement.
|
|
65
|
+
--tools-dir <dir> Pinned archify copies. Defaults to ~/.axstack/tools.
|
|
66
|
+
Must be outside skills roots and contain no symlink path.
|
|
65
67
|
--instructions <file>
|
|
66
68
|
Instruction file to receive the owned routing block.
|
|
67
69
|
Harness defaults: ~/.claude/CLAUDE.md for Claude,
|
|
@@ -105,7 +107,7 @@ function parseArgs(argv) {
|
|
|
105
107
|
}
|
|
106
108
|
out.command = rest.shift();
|
|
107
109
|
const wantsValue = new Set([
|
|
108
|
-
'--bundle', '--skills-dir', '--instructions', '--harness', '--preset', '--claude-settings',
|
|
110
|
+
'--bundle', '--skills-dir', '--tools-dir', '--instructions', '--harness', '--preset', '--claude-settings',
|
|
109
111
|
]);
|
|
110
112
|
while (rest.length > 0) {
|
|
111
113
|
const tok = rest.shift();
|
|
@@ -300,6 +302,7 @@ async function main() {
|
|
|
300
302
|
bundleDir: flags.bundle ? resolve(flags.bundle) : PACKAGE_ROOT,
|
|
301
303
|
skillsDir,
|
|
302
304
|
preset: flags.preset,
|
|
305
|
+
toolsDir: flags['tools-dir'] ? expandHome(flags['tools-dir']) : undefined,
|
|
303
306
|
instructionsPath,
|
|
304
307
|
inheritedInstructions: legacyCodexManifest?.instructions,
|
|
305
308
|
force: !!flags.force,
|
|
@@ -399,6 +402,18 @@ async function main() {
|
|
|
399
402
|
}
|
|
400
403
|
if (command === 'check') {
|
|
401
404
|
const report = await checkCapabilities(runRealCheck);
|
|
405
|
+
const toolSkillsDir = flags['skills-dir'] ? expandHome(flags['skills-dir']) :
|
|
406
|
+
flags.harness ? resolveHarnessTarget(flags.harness) : null;
|
|
407
|
+
if (toolSkillsDir) {
|
|
408
|
+
const tool = await checkArchify(toolSkillsDir);
|
|
409
|
+
console.log(`archify record: ${tool.recordFile}`);
|
|
410
|
+
if (tool.record) console.log(`archify copy: ${tool.record.path}; recorded SHA: ${tool.record.sha}; checked-out SHA: ${tool.actual ?? 'unavailable'}`);
|
|
411
|
+
if (tool.reason) {
|
|
412
|
+
console.log(`archify: ${tool.reason}`);
|
|
413
|
+
report.gaps.push(`archify: ${tool.reason}`);
|
|
414
|
+
}
|
|
415
|
+
console.log(tool.chrome ? `Chrome: ${tool.chrome}` : 'WARN Chrome: unavailable (viewer verification needs Chrome)');
|
|
416
|
+
}
|
|
402
417
|
if (flags.bundle) {
|
|
403
418
|
const bundle = await validateBundle(resolve(flags.bundle));
|
|
404
419
|
const presetNames = Object.keys(bundle.presets);
|
package/docs/installation.md
CHANGED
|
@@ -8,8 +8,10 @@ workflow database.
|
|
|
8
8
|
Requirements: Bun >=1.3.14, Git, `gh`, the `gh stack` extension, and a running
|
|
9
9
|
T3 Code `0.0.46-nightly.20261003.2610` or newer. The driver is a T3 thread
|
|
10
10
|
with the `t3-code` MCP. See the [T3 runtime boundary](../skills/axstack/references/t3-runtime.md).
|
|
11
|
-
|
|
12
|
-
and `node:fs/promises
|
|
11
|
+
Axstack has no runtime package dependencies. Filesystem access uses Bun-backed
|
|
12
|
+
`node:fs` and `node:fs/promises`. The pinned archify tool is the sole external-tool
|
|
13
|
+
exception: installation uses Git and the network, and Bun runs its Node-oriented
|
|
14
|
+
code. This exception introduces no Node.js runtime requirement.
|
|
13
15
|
|
|
14
16
|
## T3 setup
|
|
15
17
|
|
|
@@ -45,7 +47,7 @@ and its configured home channel under recorded notification authority.
|
|
|
45
47
|
### Install
|
|
46
48
|
|
|
47
49
|
```text
|
|
48
|
-
axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-dir <dir> [--instructions <file>] [--claude-settings <file>|--no-claude-settings] [--harness <name>] [--force] [--yes]
|
|
50
|
+
axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-dir <dir> [--tools-dir <dir>] [--instructions <file>] [--claude-settings <file>|--no-claude-settings] [--harness <name>] [--force] [--yes]
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
- `--preset` is required. `codex` and `claude` are aliases for the canonical
|
|
@@ -56,6 +58,9 @@ axstack install --preset <mixed|codex-only|claude-only> --bundle <dir> --skills-
|
|
|
56
58
|
- `--skills-dir` is required unless a verified harness default resolves it.
|
|
57
59
|
Codex defaults to the shared `~/.agents/skills` root; an explicit override
|
|
58
60
|
remains authoritative and disables automatic legacy Codex-root retirement.
|
|
61
|
+
- `--tools-dir` defaults to `~/.axstack/tools`. It must not overlap a skills
|
|
62
|
+
root or pass through a symlink. Home writes require `--yes`, including the
|
|
63
|
+
default tools directory.
|
|
59
64
|
- `--instructions` selects the instruction file that receives Axstack's owned
|
|
60
65
|
marker block. `--harness claude` defaults to `~/.claude/CLAUDE.md`;
|
|
61
66
|
`--harness codex` defaults to `$CODEX_HOME/AGENTS.md` or `~/.codex/AGENTS.md`;
|
|
@@ -90,6 +95,16 @@ the settings sidecar preserves the value while any other install still owns it.
|
|
|
90
95
|
- `--yes` confirms writes under the user's home directory. Tests use temporary
|
|
91
96
|
homes and fixtures only.
|
|
92
97
|
|
|
98
|
+
Installation sparse-clones archify at the reviewed full SHA in `src/archify-pin.js`
|
|
99
|
+
into `<tools-dir>/archify-<sha>`. It verifies HEAD before use and keeps the
|
|
100
|
+
payload's licence and third-party notices. The manifest owns
|
|
101
|
+
`<skills-dir>/axstack-diagram/archify.json`, which records `{path, sha}`.
|
|
102
|
+
The adjacent `<tools-dir>/archify-<sha>.owners.json` lists its skills-root owners.
|
|
103
|
+
Multiple roots share one copy. Repeat installs leave unchanged files untouched.
|
|
104
|
+
An offline, Git-less, or failed clone still installs the skills, reports
|
|
105
|
+
`archify: unavailable (<reason>)`, and exits 0. An existing SHA mismatch fails.
|
|
106
|
+
Tests use a local repository through `AXSTACK_ARCHIFY_REPO` and never use the network.
|
|
107
|
+
|
|
93
108
|
## Role presets
|
|
94
109
|
|
|
95
110
|
The selected bundle input is one of:
|
|
@@ -121,6 +136,13 @@ owned, missing, unowned, edited, or bound to a different path. Hand-written
|
|
|
121
136
|
legacy routing outside the owned block is reported for manual migration and
|
|
122
137
|
preserved byte for byte.
|
|
123
138
|
|
|
139
|
+
With `--skills-dir` or `--harness`, check reports the archify record path, copy
|
|
140
|
+
path, recorded SHA, and checked-out SHA. A missing record, missing copy, or SHA
|
|
141
|
+
mismatch fails the check. Chrome absence prints a warning and does not fail it.
|
|
142
|
+
`ARCHIFY_CHROME` selects the Chrome executable. Without it, check looks for
|
|
143
|
+
common Chrome and Chromium executables on PATH. A check without a skills target
|
|
144
|
+
probes host capabilities only.
|
|
145
|
+
|
|
124
146
|
A successful check is not provider/model availability, effective permission,
|
|
125
147
|
skill reload, task execution, mobile delivery, or end-to-end compatibility
|
|
126
148
|
proof. Those require their own runtime receipts.
|
|
@@ -135,6 +157,15 @@ Uninstall removes only unchanged Axstack-owned files whose current bytes match
|
|
|
135
157
|
the manifest. Edited, custom, unknown, and unrelated files survive. Directories
|
|
136
158
|
are pruned only when empty, and the target root is never removed.
|
|
137
159
|
|
|
160
|
+
Uninstall drops that skills root from archify's owners file. It removes the copy
|
|
161
|
+
only when no owners remain, HEAD matches the recorded SHA, and
|
|
162
|
+
`git status --porcelain --ignored` is empty. Otherwise it keeps the copy and
|
|
163
|
+
reports why, even with `--force`. A pin bump or a changed `--tools-dir` releases
|
|
164
|
+
the old copy through the same guard. Installation clones into a temporary sibling
|
|
165
|
+
and renames it into place. A later owner or manifest write failure restores
|
|
166
|
+
ownership and removes only a copy created by that installation. Older manifests
|
|
167
|
+
without an archify record still load.
|
|
168
|
+
|
|
138
169
|
## Owned instruction block
|
|
139
170
|
|
|
140
171
|
The deterministic `<!-- axstack:begin v1 -->` / `<!-- axstack:end -->` block
|
|
@@ -323,8 +354,8 @@ need separate authority and verified backups.
|
|
|
323
354
|
## Examples
|
|
324
355
|
|
|
325
356
|
```sh
|
|
326
|
-
axstack install --preset mixed --bundle ./bundle --skills-dir /tmp/ax-skills --instructions /tmp/AGENTS.md
|
|
327
|
-
axstack install --preset mixed --bundle ./bundle --skills-dir /tmp/ax-skills --instructions /tmp/AGENTS.md
|
|
357
|
+
axstack install --preset mixed --bundle ./bundle --skills-dir /tmp/ax-skills --tools-dir /tmp/ax-tools --instructions /tmp/AGENTS.md
|
|
358
|
+
axstack install --preset mixed --bundle ./bundle --skills-dir /tmp/ax-skills --tools-dir /tmp/ax-tools --instructions /tmp/AGENTS.md
|
|
328
359
|
axstack check --bundle ./bundle --skills-dir /tmp/ax-skills --instructions /tmp/AGENTS.md
|
|
329
360
|
axstack uninstall --skills-dir /tmp/ax-skills --instructions /tmp/AGENTS.md
|
|
330
361
|
```
|
package/docs/workflows.md
CHANGED
|
@@ -22,6 +22,11 @@ Direct routes need no spec ceremony:
|
|
|
22
22
|
Only the user invokes it.
|
|
23
23
|
- `axstack-explain` separates implemented, intended, tested, live, and unknown
|
|
24
24
|
behavior; complex visuals receive exact-artifact QA where applicable.
|
|
25
|
+
- [axstack-diagram](../skills/axstack-diagram/SKILL.md) selects Mermaid for chat,
|
|
26
|
+
GitHub, and docs, or an interactive viewer for required complex visuals.
|
|
27
|
+
Explain loads it for every diagram. Viewers use
|
|
28
|
+
[archify](https://github.com/tt-a1i/archify) (MIT) with a pinned tool,
|
|
29
|
+
a passing finalize receipt, rendered QA, and node-and-edge source review.
|
|
25
30
|
- `axstack-improve` returns a small ranked set of evidenced improvement
|
|
26
31
|
candidates without editing code. Its test-audit lens marks every declaration
|
|
27
32
|
in one owner boundary R/F/C/D, reports reviewed and eligible counts, and routes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "axstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks T3 Code capabilities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -34,7 +34,7 @@ T3 sessions are exempt from Claude trust preflight.
|
|
|
34
34
|
|
|
35
35
|
## Session admission
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
The owner-of-record performs guarded retirement in [Finite-session teardown](#finite-session-teardown).
|
|
38
38
|
Lane identity requires exact thread/run identity, never a title or directory-name
|
|
39
39
|
guess; never infer lane ownership from an empty local workspace.
|
|
40
40
|
Reconcile saved state, current GitHub state, and native T3 threads, runs,
|
|
@@ -54,8 +54,36 @@ events and the unserved count. If a duplicate finds a stalled owner idle at its
|
|
|
54
54
|
prompt with a final turn lacking a completion receipt for more than five
|
|
55
55
|
minutes (nudged or not), record it in that private note and send one deduplicated
|
|
56
56
|
notification under the recorded `Notification policy`.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
A **wedged lane pass**, whether owner, successor or duplicate, has a non-terminal
|
|
58
|
+
run with more than 60 minutes of verified inactivity across its thread, all its
|
|
59
|
+
runs, delegated tasks and descendant threads, and is free of `user_takeover`.
|
|
60
|
+
A user-taken-over descendant holds recovery and excludes its pass from the
|
|
61
|
+
wedged category, and is never interrupted.
|
|
62
|
+
A live pass has a non-terminal run and is not wedged.
|
|
63
|
+
Exactly one successor recovers a wedged owner or resumes an interrupted takeover:
|
|
64
|
+
the earliest-started live pass, selected by native run start
|
|
65
|
+
ordering; missing or tied ordering evidence holds admission and recovery.
|
|
66
|
+
The successor interrupts the owner and its descendant threads with
|
|
67
|
+
`t3_thread_interrupt`, naming each exact `runId` and a stable `clientRequestId`
|
|
68
|
+
per run, and reads back terminal state for the whole tree
|
|
69
|
+
before recording the takeover in continuity and becoming owner.
|
|
70
|
+
If a successor wedges between interrupt and takeover, the next elected live pass
|
|
71
|
+
interrupts the wedged successor and its tree with `t3_thread_interrupt` at each
|
|
72
|
+
exact `runId` using the same per-run procedure, and reads back terminal state for
|
|
73
|
+
that whole tree before recording any takeover, then resumes recovery from terminal readback
|
|
74
|
+
of the interrupted owner, which is
|
|
75
|
+
excluded from the potentially-live-owner hold.
|
|
76
|
+
The owner-of-record, including a successor immediately after takeover, interrupts
|
|
77
|
+
wedged non-owner passes (duplicates or failed successors) using the same exact-run
|
|
78
|
+
procedure and terminal readback, then retires them under Finite-session teardown.
|
|
79
|
+
Before any admission, the successor reconciles the previous owner's in-flight
|
|
80
|
+
jobs from GitHub and evidence. Only a verdict already published and read back
|
|
81
|
+
counts as served. Otherwise the event is unserved/`INCOMPLETE` and re-eligible.
|
|
82
|
+
A failed interrupt or unverified terminal state keeps the existing hold plus
|
|
83
|
+
one deduplicated notification under the recorded `Notification policy`.
|
|
84
|
+
Outside the wedged-pass recovery above, unknown liveness blocks admission,
|
|
85
|
+
shared-record writes, takeover and cleanup, including when activity is recent.
|
|
86
|
+
Preserve user-taken-over threads.
|
|
59
87
|
This is prompt policy, not an atomic lock: the overlap canary must demonstrate
|
|
60
88
|
one admission owner before activation. Count all unsettled PR jobs and descendants.
|
|
61
89
|
|
|
@@ -132,7 +160,9 @@ under [T3 runtime](t3-runtime.md). For a worker's own brief question, confirm th
|
|
|
132
160
|
brief once.
|
|
133
161
|
A second brief ask follows the five-minute stop rule, never an open-ended hold.
|
|
134
162
|
An idle final turn without a valid completion
|
|
135
|
-
receipt is incomplete, not successful. A
|
|
163
|
+
receipt is incomplete, not successful. A wait with more than 60 minutes of
|
|
164
|
+
verified inactivity across its whole tree is a wedge, not a running wait.
|
|
165
|
+
A started coordinator waiting on its
|
|
136
166
|
reviewers (a live reviewer task or running wait) is not idle and is never
|
|
137
167
|
stopped for waiting. Reconcile terminal failure and all descendants before
|
|
138
168
|
recording an event unserved and re-admissible.
|
|
@@ -260,16 +290,21 @@ or separate model gate.
|
|
|
260
290
|
|
|
261
291
|
## Finite-session teardown
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
293
|
+
Only the owner-of-record pass retires settled predecessor passes and qualifying
|
|
294
|
+
terminal duplicates, including duplicates newer than the owner and immediately
|
|
295
|
+
after successor takeover.
|
|
296
|
+
Qualifying duplicates have a terminal run, no descendants and only their own pass note.
|
|
297
|
+
Each pass reports the retained worktree count.
|
|
298
|
+
Retirement requires terminal run evidence and settled descendants,
|
|
265
299
|
durable continuity, evidence read-back and verified salvage where needed;
|
|
266
300
|
follow [Workspace hygiene](workspace-hygiene.md) and [T3 runtime](t3-runtime.md).
|
|
267
301
|
Then run the driver-start orphan sweep under Workspace hygiene; the sweep is
|
|
268
302
|
silent when nothing was removed. Record sweep results and holds in continuity's
|
|
269
303
|
Open holds table.
|
|
270
304
|
The orphan sweep covers the run record's repositories plus registered repositories on this host.
|
|
271
|
-
`t3_thread_organize` settle/archive
|
|
272
|
-
|
|
305
|
+
Retirement settles duplicate threads with `t3_thread_organize` (settle/archive
|
|
306
|
+
changes metadata only) before separate guarded Git worktree removal.
|
|
307
|
+
Unknown, active or user-taken-over threads,
|
|
273
308
|
ambiguous publication and failed salvage stay preserved.
|
|
274
309
|
|
|
275
310
|
A review-manager pass may retire a predecessor pass's local `t3code/*` branch
|
|
@@ -295,7 +330,8 @@ removal, not settled execution capacity. The next pass retires this settled pass
|
|
|
295
330
|
|
|
296
331
|
The canary runs two overlapping `run_scheduled_task_now` passes and proves one
|
|
297
332
|
admission owner per PR. The canary reviews or correctly no-ops one real PR event.
|
|
298
|
-
The canary reconciles a killed predecessor
|
|
333
|
+
The canary reconciles a killed predecessor and demonstrates wedged-pass recovery:
|
|
334
|
+
exactly one successor interrupts and retires a wedged predecessor.
|
|
299
335
|
With a temporary limit equal to the current count, the canary disables the
|
|
300
336
|
schedule and verifies that the following interval creates zero new pass worktrees.
|
|
301
337
|
The previous review-manager automation is disabled, never deleted, only after all four T3 canary checks pass.
|
|
@@ -5,6 +5,12 @@ browser" step, goes through async `delegate_task` to `axstack-ui-verifier` from
|
|
|
5
5
|
run's role snapshot. Give it the exact build, URL, or artifact and the private
|
|
6
6
|
dispatch's evidence folder. The verifier is read-only: it never edits source.
|
|
7
7
|
The PR writer remains the sole writer.
|
|
8
|
+
|
|
9
|
+
The sole author browser exception is archify `finalize` as a headless build gate.
|
|
10
|
+
Keep finalize outputs only in the private evidence folder.
|
|
11
|
+
Never replace the verifier's rendered pass with finalize.
|
|
12
|
+
All other browser checks remain delegated to `axstack-ui-verifier`.
|
|
13
|
+
|
|
8
14
|
Read the [T3 runtime boundary](t3-runtime.md) before dispatch and use its
|
|
9
15
|
driver-made disposable detached checkout at the pinned candidate SHA.
|
|
10
16
|
The verifier uses T3 `preview_*` tools for rendered checks.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: axstack-diagram
|
|
3
|
+
description: When an explanation needs a diagram, use axstack-diagram to choose Mermaid or a pinned archify viewer with source fidelity and rendered QA.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Diagram
|
|
7
|
+
|
|
8
|
+
Usage: `/axstack-diagram <question + destination>` or load this skill from explain.
|
|
9
|
+
Load [Standing contracts](../axstack/references/contracts.md) before acting.
|
|
10
|
+
Follow [Fidelity](references/fidelity.md) for every format.
|
|
11
|
+
|
|
12
|
+
Use archify HTML for an explicit viewer request or explain's complex-visual path.
|
|
13
|
+
For the viewer, follow [Archify](references/archify.md).
|
|
14
|
+
Otherwise use Mermaid for chat, GitHub, or docs.
|
|
15
|
+
Include `accTitle` and `accDescr` in each Mermaid view.
|
|
16
|
+
Never use theme init in Mermaid.
|
|
17
|
+
Split large Mermaid diagrams into views.
|
|
18
|
+
T3 chat renders the Mermaid fence once the reply settles.
|
|
19
|
+
|
|
20
|
+
Explain owns evidence, claim labels, the word cap, dispatch, and delivery.
|
|
21
|
+
Diagram owns format and visual rules.
|
|
22
|
+
Never call explain.
|
|
23
|
+
For direct use, keep fidelity and rendered QA.
|
|
24
|
+
|
|
25
|
+
Ideas: [archify](https://github.com/tt-a1i/archify), [diagram-design](https://github.com/cathrynlavery/diagram-design), [visual-explainer](https://github.com/nicobailon/visual-explainer), [Matt Pocock](https://github.com/mattpocock/skills), and [poteto](https://github.com/poteto/how); guidance uses Axstack's own words.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Archify
|
|
2
|
+
|
|
3
|
+
Archify turns a typed JSON intermediate representation (IR) into an HTML viewer.
|
|
4
|
+
Follow [Fidelity](fidelity.md) before authoring.
|
|
5
|
+
|
|
6
|
+
## Bind the tool
|
|
7
|
+
|
|
8
|
+
Read `archify.json` beside the skill entrypoint for its `{path, sha}` record.
|
|
9
|
+
Verify that `git -C <path> rev-parse HEAD` equals the recorded SHA.
|
|
10
|
+
If the record or copy is missing, HEAD mismatches the recorded SHA, or Chrome is missing, hold and ask the user.
|
|
11
|
+
Honor `ARCHIFY_CHROME` when set.
|
|
12
|
+
Otherwise find Chrome or Chromium on `PATH`.
|
|
13
|
+
Set `ARCHIFY_UPDATE_CHECK_DISABLED=1` on every archify call.
|
|
14
|
+
Never substitute Mermaid or unverified HTML when the viewer is required.
|
|
15
|
+
|
|
16
|
+
## Author the IR
|
|
17
|
+
|
|
18
|
+
If the pinned archify procedure conflicts with this file, this file takes precedence.
|
|
19
|
+
Read `<path>/archify/SKILL.md` and follow its named references for IR authoring and repair.
|
|
20
|
+
Run the referenced archify commands with Bun.
|
|
21
|
+
|
|
22
|
+
Set `meta.title` and a portable relative HTML filename in `meta.output`.
|
|
23
|
+
Use `meta.quality_profile: "showcase"`.
|
|
24
|
+
Set `meta.animation: "none"`.
|
|
25
|
+
Keep trace motion, exports, share cards, and brand marks off unless the user asks.
|
|
26
|
+
Deliver the viewer with `?theme=dark` unless the user names a theme.
|
|
27
|
+
Theme is viewer state rather than an IR field; without the query, localStorage and `prefers-color-scheme` determine it.
|
|
28
|
+
Preserve explain's claim labels in `meta.subtitle` or the companion explanation.
|
|
29
|
+
Set `meta.repository` to the inspected repository URL and full `revision`.
|
|
30
|
+
Keep per-node source pins in each node's `sources` entries for inspected paths and line ranges.
|
|
31
|
+
Keep the private source map for relationships that have no source field.
|
|
32
|
+
Keep proposed sources distinct from factual pins.
|
|
33
|
+
Use [Fidelity](fidelity.md) for visible planned and unknown markers.
|
|
34
|
+
For a separate check, run `bun <path>/archify/bin/archify.mjs validate <type> <ir.json> --json --repo-root <repo>`.
|
|
35
|
+
When sources are declared, require `--repo-root <repo>` on both `validate` and `finalize`.
|
|
36
|
+
For sequence and dataflow, a schema-valid legend cue is `meta.legend.entries.dashed: {"label":"Planned or unknown"}`.
|
|
37
|
+
|
|
38
|
+
Use one top-level `cards[]` entry with `{dot, title, items[]}` per key node as its node detail card.
|
|
39
|
+
Each node detail card has at most 40 words and cites its source.
|
|
40
|
+
Count the title plus all items toward each card's 40-word cap.
|
|
41
|
+
Cite each card's source inline as `file:lines` in its `items[]`.
|
|
42
|
+
Node detail cards are exempt from explain's 700-word cap.
|
|
43
|
+
Count node labels, headings, captions, and all non-card text.
|
|
44
|
+
|
|
45
|
+
## Build and hold
|
|
46
|
+
|
|
47
|
+
Write the IR, HTML, and receipt only inside the private evidence folder.
|
|
48
|
+
For direct invocation, create a private run evidence folder first.
|
|
49
|
+
Override archify's default `.archify/` folder with an evidence output path and `--out-dir <evidence dir>`.
|
|
50
|
+
Run `bun <path>/archify/bin/archify.mjs finalize <type> <ir.json> <out.html> --quality showcase --json --out-dir <evidence dir> --repo-root <repo>`.
|
|
51
|
+
This is the author's headless build gate.
|
|
52
|
+
Read the saved receipt named by the JSON result's `evidence.receipt`.
|
|
53
|
+
Accept only receipt `status: "pass"` as success.
|
|
54
|
+
Never trust exit codes as proof of a pass.
|
|
55
|
+
When a receipt is missing or remains non-pass after two repair rounds, hold delivery and ask the user.
|
|
56
|
+
If Chrome is missing, report "viewer not verified: Chrome missing".
|
|
57
|
+
Chrome absence yields `status: "skipped"` and exit 2.
|
|
58
|
+
Follow archify's receipt fixes and references for layout and composition repairs.
|
|
59
|
+
After every repair edit, rerun the complete `finalize` command.
|
|
60
|
+
Count one repair round as one complete `finalize` rerun after an edit.
|
|
61
|
+
Validate runs never count as repair rounds.
|
|
62
|
+
Do not require validate before finalize.
|
|
63
|
+
Run at most two repair rounds, then hold with remaining defects for the user's decision.
|
|
64
|
+
Never use archify's extra evidence-based retry.
|
|
65
|
+
|
|
66
|
+
## Rendered and source checks
|
|
67
|
+
|
|
68
|
+
Follow [UI verification](../../axstack/references/ui-verification.md) for dispatch.
|
|
69
|
+
Keep `finalize` as the only author browser check.
|
|
70
|
+
Delegate `visual-check`, browser opening, preview, and first-screen inspection to `axstack-ui-verifier`.
|
|
71
|
+
Require `axstack-ui-verifier` checks of the receipt's `artifact.sha256` bytes on desktop, 390px mobile, keyboard, reduced motion, and the text alternative.
|
|
72
|
+
Include theme, search or focus, and directional reach interactions.
|
|
73
|
+
Require `axstack-ui-verifier` checks with the theme set explicitly.
|
|
74
|
+
Require `explainer-review` of every node and edge against source for archify output.
|
|
75
|
+
Every byte change requires fresh checks.
|
|
76
|
+
Keep these independent verdicts with the build receipt before delivery.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Fidelity
|
|
2
|
+
|
|
3
|
+
Map factual nodes and edges to an inspected source path at a recorded full revision.
|
|
4
|
+
Drop unsupported factual elements.
|
|
5
|
+
For uncommitted sources, use the content hash and mark their pins unknown.
|
|
6
|
+
Keep the source map outside the reader view.
|
|
7
|
+
Display the short SHA.
|
|
8
|
+
|
|
9
|
+
Give planned and unknown nodes a visible marker in the node label and their card, if any.
|
|
10
|
+
Use the literal marker `planned` or `unknown` for all five types.
|
|
11
|
+
Give planned and unknown edges a visible `planned` or `unknown` marker in the edge label.
|
|
12
|
+
For dashed styling, use only properties the pinned schema accepts and verify with `validate`.
|
|
13
|
+
This is an extra cue, such as a dashed relationship where supported.
|
|
14
|
+
The pinned node collections reject `variant`.
|
|
15
|
+
Add a planned or unknown legend entry only for sequence and dataflow.
|
|
16
|
+
Architecture, workflow, and lifecycle reject a `dashed` legend entry.
|
|
17
|
+
In Mermaid, use the same visible markers and a dashed cue where its syntax permits.
|
|
18
|
+
|
|
19
|
+
Treat about 9 core nodes as a signal to split.
|
|
20
|
+
This is a design signal rather than a hard gate.
|
|
21
|
+
Use 1–2 accent nodes.
|
|
22
|
+
Keep labels short and name each relationship's direction and meaning.
|
|
23
|
+
Provide a text alternative that describes nodes and edges.
|
|
24
|
+
Never load network assets in the HTML.
|
|
25
|
+
Write diagrams into repo docs only when the user asks.
|
|
@@ -51,6 +51,10 @@ immediately before an actual profile dispatch.
|
|
|
51
51
|
|
|
52
52
|
## 2. Choose proportional output
|
|
53
53
|
|
|
54
|
+
Load [Diagram](../axstack-diagram/SKILL.md) for every diagram.
|
|
55
|
+
Explain owns evidence gathering, claim labels, the 700-word cap, dispatch, and delivery.
|
|
56
|
+
Diagram never calls explain.
|
|
57
|
+
|
|
54
58
|
1. Keep the primary reader-facing explanation to a maximum of 700 words in
|
|
55
59
|
chat, HTML, and every other requested format. Preserve in that primary view
|
|
56
60
|
the answer or purpose, key rationale, meaningful alternatives, main data or
|
|
@@ -63,11 +67,17 @@ immediately before an actual profile dispatch.
|
|
|
63
67
|
maximum. If the draft is longer, compress repetition first and move only
|
|
64
68
|
supporting detail to a separate linked ticket or appendix. Essential answers
|
|
65
69
|
must not be hidden behind links, and evidence must not be silently discarded.
|
|
70
|
+
Only archify node detail cards are exempt from the 700-word cap.
|
|
71
|
+
Each card has at most 40 words and cites its source, as defined in
|
|
72
|
+
[Archify](../axstack-diagram/references/archify.md).
|
|
73
|
+
Count node labels, headings, captions, and all non-card text.
|
|
66
74
|
2. For a simple request, answer concisely in the current chat. Use a compact
|
|
67
75
|
diagram when useful. This needs no mandatory agent or intermediate artifact.
|
|
76
|
+
For simple chat answers, never dispatch an agent when applying its Mermaid rules inline.
|
|
68
77
|
3. For a complex visual, use the configured `axstack-explainer` role to create
|
|
69
|
-
self-contained HTML
|
|
70
|
-
|
|
78
|
+
self-contained HTML through the archify path by default.
|
|
79
|
+
For a complex visual, honor an explicitly requested artifact format.
|
|
80
|
+
An explicit user theme wins; otherwise use the dark default.
|
|
71
81
|
4. Profile IDs are presets, not availability proof. Before dispatch, follow the
|
|
72
82
|
launch sequence and preserve the configured model, mode, and effort. Report
|
|
73
83
|
an unavailable route; never substitute a model.
|
|
@@ -77,8 +87,9 @@ immediately before an actual profile dispatch.
|
|
|
77
87
|
1. Any HTML explanation requires the full [visual QA
|
|
78
88
|
checklist](references/visual-qa.md): actual desktop and mobile rendering,
|
|
79
89
|
interaction, accessibility, and reduced-motion checks where relevant.
|
|
80
|
-
2.
|
|
81
|
-
|
|
90
|
+
2. For archify output, require the configured independent `axstack-explainer-review`;
|
|
91
|
+
for other artifacts, use it when warranted. Bind it to the exact artifact identity.
|
|
92
|
+
Any byte change invalidates
|
|
82
93
|
that review and requires a fresh check. In `claude-only`, separate Sonnet
|
|
83
94
|
author xhigh and reviewer high sessions are allowed for explanations as
|
|
84
95
|
session independence only. This exception never permits same-model code
|
|
@@ -7,6 +7,8 @@ Browser and visual checks must run in the delegated `axstack-ui-verifier` in its
|
|
|
7
7
|
|
|
8
8
|
1. Identify the final artifact bytes and theme. The explicit user theme wins;
|
|
9
9
|
otherwise use the dark default.
|
|
10
|
+
For archify output, bind the `axstack-ui-verifier` pass to the finalize receipt's `artifact.sha256`.
|
|
11
|
+
For archify output, bind `axstack-explainer-review` to the same finalize receipt's `artifact.sha256`.
|
|
10
12
|
2. Delegate the rendered pass through [UI verification](../../axstack/references/ui-verification.md).
|
|
11
13
|
Record actual desktop and mobile observations, or name the missing layout
|
|
12
14
|
check.
|
|
@@ -16,5 +18,6 @@ Browser and visual checks must run in the delegated `axstack-ui-verifier` in its
|
|
|
16
18
|
4. The explainer reviewer checks text and source fidelity. Keep source
|
|
17
19
|
correctness, tests, rendered behavior, independent review, and publication
|
|
18
20
|
evidence separate.
|
|
21
|
+
For archify output, require `axstack-explainer-review` of every node and edge against source.
|
|
19
22
|
5. Bind review to the exact artifact identity. Any byte change invalidates the
|
|
20
23
|
affected approval and requires fresh QA and review.
|
package/src/archify.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from './posixpath.js';
|
|
3
|
+
import { ARCHIFY_PIN } from './archify-pin.js';
|
|
4
|
+
|
|
5
|
+
export const ARCHIFY_RECORD = 'axstack-diagram/archify.json';
|
|
6
|
+
|
|
7
|
+
export function archifyGit(path, ...args) {
|
|
8
|
+
try {
|
|
9
|
+
const result = Bun.spawnSync(['git', ...(path ? ['-C', path] : []), ...args], {
|
|
10
|
+
stdout: 'pipe', stderr: 'pipe', timeout: 60000,
|
|
11
|
+
env: { ...Bun.env, GIT_TERMINAL_PROMPT: '0' },
|
|
12
|
+
});
|
|
13
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.toString().trim() || `git exit ${result.exitCode}`);
|
|
14
|
+
return result.stdout.toString().trim();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(`git: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifySha(record, actual) {
|
|
21
|
+
if (actual === record.sha) return;
|
|
22
|
+
const error = new Error(`archify SHA mismatch: expected ${record.sha}, got ${actual}`);
|
|
23
|
+
error.code = 'ARCHIFY_SHA_MISMATCH';
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function ownersSnapshot(path) {
|
|
28
|
+
const st = await lstat(path).catch((err) => {
|
|
29
|
+
if (err.code === 'ENOENT') return null;
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
if (st && (!st.isFile() || st.isSymbolicLink())) throw new Error(`unsafe archify owners file: ${path}`);
|
|
33
|
+
const bytes = st ? await readFile(path) : null;
|
|
34
|
+
const owners = bytes ? JSON.parse(bytes) : [];
|
|
35
|
+
if (!Array.isArray(owners) || owners.some((owner) => typeof owner !== 'string' || !owner.startsWith('/'))) {
|
|
36
|
+
throw new Error(`invalid archify owners file: ${path}`);
|
|
37
|
+
}
|
|
38
|
+
return { bytes, owners, mode: st ? st.mode & 0o777 : null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Drop ownership transactionally; defer destructive cleanup until the skills
|
|
42
|
+
// manifest commits. A failed manifest write can then restore the owner list.
|
|
43
|
+
export async function planArchifyRelease({ record, skillsRoot, writeAtomic, log }) {
|
|
44
|
+
const ownersFile = `${record.path}.owners.json`;
|
|
45
|
+
const before = await ownersSnapshot(ownersFile);
|
|
46
|
+
const remaining = before.owners.filter((owner) => owner !== skillsRoot);
|
|
47
|
+
if (!before.owners.includes(skillsRoot)) {
|
|
48
|
+
return { rollback: async () => {}, finish: async () => log(`archify: kept ${record.path} (owner is absent)`) };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await writeAtomic(ownersFile, JSON.stringify(remaining) + '\n', { mode: 0o600 });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const current = await readFile(ownersFile);
|
|
54
|
+
if (!current.equals(before.bytes)) {
|
|
55
|
+
try { await writeAtomic(ownersFile, before.bytes, { mode: before.mode }); }
|
|
56
|
+
catch (restoreErr) { err.message += ` (archify owner restore failed: ${restoreErr.message})`; }
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
rollback: () => writeAtomic(ownersFile, before.bytes, { mode: before.mode }),
|
|
62
|
+
finish: async () => {
|
|
63
|
+
try {
|
|
64
|
+
if (remaining.length) throw new Error('other skills roots still own this copy');
|
|
65
|
+
const st = await lstat(record.path);
|
|
66
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('copy is not a real directory');
|
|
67
|
+
if (archifyGit(record.path, 'rev-parse', 'HEAD') !== record.sha) throw new Error('HEAD differs from the recorded SHA');
|
|
68
|
+
if (archifyGit(record.path, 'status', '--porcelain', '--ignored')) throw new Error('copy has tracked, untracked, or ignored changes');
|
|
69
|
+
await rm(record.path, { recursive: true });
|
|
70
|
+
await rm(ownersFile);
|
|
71
|
+
log(`archify: removed ${record.path}`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log(`archify: kept ${record.path} (${err.message})`);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The caller includes this transaction in the skills/manifest rollback.
|
|
80
|
+
export async function provisionArchify({ toolsRoot, skillsRoot, writeAtomic, log }) {
|
|
81
|
+
if (!Bun.which('git')) {
|
|
82
|
+
log('archify: unavailable (git is not on PATH)');
|
|
83
|
+
return { record: null, rollback: async () => {} };
|
|
84
|
+
}
|
|
85
|
+
const record = { path: join(toolsRoot, `archify-${ARCHIFY_PIN.sha}`), sha: ARCHIFY_PIN.sha };
|
|
86
|
+
const ownersFile = `${record.path}.owners.json`;
|
|
87
|
+
const before = await ownersSnapshot(ownersFile);
|
|
88
|
+
let created = false;
|
|
89
|
+
let ownersWritten = false;
|
|
90
|
+
const rollback = async () => {
|
|
91
|
+
const errors = [];
|
|
92
|
+
if (ownersWritten) {
|
|
93
|
+
try {
|
|
94
|
+
if (before.bytes === null) await rm(ownersFile);
|
|
95
|
+
else await writeAtomic(ownersFile, before.bytes, { mode: before.mode });
|
|
96
|
+
} catch (err) { errors.push(`${ownersFile}: ${err.message}`); }
|
|
97
|
+
}
|
|
98
|
+
if (created) {
|
|
99
|
+
try { await rm(record.path, { recursive: true }); }
|
|
100
|
+
catch (err) { errors.push(`${record.path}: ${err.message}`); }
|
|
101
|
+
}
|
|
102
|
+
if (errors.length) throw new Error(`incomplete archify rollback: ${errors.join('; ')}`);
|
|
103
|
+
};
|
|
104
|
+
const existing = await lstat(record.path).catch((err) => {
|
|
105
|
+
if (err.code === 'ENOENT') return null;
|
|
106
|
+
throw err;
|
|
107
|
+
});
|
|
108
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) {
|
|
109
|
+
throw new Error(`unsafe archify copy: ${record.path}`);
|
|
110
|
+
}
|
|
111
|
+
if (existing) {
|
|
112
|
+
const actual = archifyGit(record.path, 'rev-parse', 'HEAD');
|
|
113
|
+
verifySha(record, actual);
|
|
114
|
+
} else {
|
|
115
|
+
let temp = null;
|
|
116
|
+
try {
|
|
117
|
+
await mkdir(toolsRoot, { recursive: true });
|
|
118
|
+
temp = await mkdtemp(join(toolsRoot, '.archify-'));
|
|
119
|
+
archifyGit(null, 'clone', '--filter=blob:none', '--no-checkout', '--', Bun.env.AXSTACK_ARCHIFY_REPO || ARCHIFY_PIN.repo, temp);
|
|
120
|
+
archifyGit(temp, 'sparse-checkout', 'set', '--no-cone', '/archify/');
|
|
121
|
+
archifyGit(temp, 'checkout', '--detach', record.sha);
|
|
122
|
+
const actual = archifyGit(temp, 'rev-parse', 'HEAD');
|
|
123
|
+
verifySha(record, actual);
|
|
124
|
+
await rename(temp, record.path);
|
|
125
|
+
temp = null;
|
|
126
|
+
created = true;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.code === 'ARCHIFY_SHA_MISMATCH') throw err;
|
|
129
|
+
log(`archify: unavailable (${err.message.replace(/\s+/g, ' ').slice(0, 180)})`);
|
|
130
|
+
return { record: null, rollback: async () => {} };
|
|
131
|
+
} finally {
|
|
132
|
+
if (temp) await rm(temp, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (!before.owners.includes(skillsRoot)) {
|
|
137
|
+
ownersWritten = true;
|
|
138
|
+
await writeAtomic(ownersFile, JSON.stringify([...before.owners, skillsRoot].sort()) + '\n', { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const current = await readFile(ownersFile).catch(() => null);
|
|
142
|
+
ownersWritten = before.bytes === null ? current !== null : current?.equals(before.bytes) !== true;
|
|
143
|
+
try { await rollback(); }
|
|
144
|
+
catch (restoreErr) { err.message += ` (${restoreErr.message})`; }
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
log(`archify: ${record.path} (${record.sha})`);
|
|
148
|
+
return { record, rollback };
|
|
149
|
+
}
|
package/src/capabilities.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Host capability checks. Injected execution keeps tests off live runtimes.
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from './posixpath.js';
|
|
4
|
+
import { ARCHIFY_RECORD, archifyGit } from './archify.js';
|
|
5
|
+
import { ARCHIFY_PIN } from './archify-pin.js';
|
|
2
6
|
export const BUN_FLOOR = '1.3.14';
|
|
3
7
|
export const T3_FLOOR = '0.0.46-nightly.20261003.2610';
|
|
4
8
|
|
|
@@ -106,3 +110,29 @@ export async function checkCapabilities(exec) {
|
|
|
106
110
|
const gaps = checks.filter((c) => !c.ok).map((c) => `missing ${c.label}`);
|
|
107
111
|
return { checks, gaps, limitations: [...PROBE_LIMITATIONS] };
|
|
108
112
|
}
|
|
113
|
+
|
|
114
|
+
export async function checkArchify(skillsDir) {
|
|
115
|
+
const recordFile = join(skillsDir, ARCHIFY_RECORD);
|
|
116
|
+
const override = Bun.env.ARCHIFY_CHROME;
|
|
117
|
+
const chrome = override ? Bun.which(override) :
|
|
118
|
+
['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']
|
|
119
|
+
.map((name) => Bun.which(name)).find(Boolean);
|
|
120
|
+
let record = null;
|
|
121
|
+
let actual = null;
|
|
122
|
+
let reason = null;
|
|
123
|
+
try {
|
|
124
|
+
if ((await lstat(recordFile)).isSymbolicLink()) throw new Error('record is a symlink');
|
|
125
|
+
record = JSON.parse(await readFile(recordFile, 'utf8'));
|
|
126
|
+
if (!record || typeof record.path !== 'string' || !record.path.startsWith('/') ||
|
|
127
|
+
!/^[a-f0-9]{40}$/.test(record.sha)) throw new Error('invalid record');
|
|
128
|
+
const copy = await lstat(record.path);
|
|
129
|
+
if (!copy.isDirectory() || copy.isSymbolicLink()) throw new Error('copy is not a real directory');
|
|
130
|
+
actual = archifyGit(record.path, 'rev-parse', 'HEAD');
|
|
131
|
+
if (actual !== record.sha || record.sha !== ARCHIFY_PIN.sha) {
|
|
132
|
+
throw new Error(`SHA mismatch: recorded ${record.sha}, checked-out ${actual}, expected ${ARCHIFY_PIN.sha}`);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
reason = err.code === 'ENOENT' ? 'missing record or copy' : err.message;
|
|
136
|
+
}
|
|
137
|
+
return { recordFile, record, actual, chrome: chrome ?? null, reason };
|
|
138
|
+
}
|
package/src/installer.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
writeManifest,
|
|
39
39
|
} from './manifest.js';
|
|
40
40
|
import { planInstallClaudeSettings, planUninstallClaudeSettings } from './claude-settings.js';
|
|
41
|
+
import { ARCHIFY_RECORD, planArchifyRelease, provisionArchify } from './archify.js';
|
|
41
42
|
import {
|
|
42
43
|
applyInstructionPlan,
|
|
43
44
|
findLegacyRoutingLines,
|
|
@@ -205,6 +206,15 @@ export function assertOutsideHome(target, { yes = false, kind = 'target' } = {})
|
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
async function guardArchifyRecord(record, skillsRoot, yes) {
|
|
210
|
+
if (!record) return;
|
|
211
|
+
await assertNoSymlinksBelow('/', record.path);
|
|
212
|
+
if (withinRoot(record.path, skillsRoot) || withinRoot(skillsRoot, dirname(record.path))) {
|
|
213
|
+
throw new Error('recorded archify tools directory overlaps the skills root; refusing');
|
|
214
|
+
}
|
|
215
|
+
assertOutsideHome(record.path, { yes, kind: 'archify copy' });
|
|
216
|
+
}
|
|
217
|
+
|
|
208
218
|
// Validate the bundle directory and return its payload. Throws before any
|
|
209
219
|
// target mutation on any problem.
|
|
210
220
|
export async function validateBundle(bundleDir, selectedPreset = null) {
|
|
@@ -461,6 +471,7 @@ export async function installBundle({
|
|
|
461
471
|
bundleDir,
|
|
462
472
|
skillsDir,
|
|
463
473
|
preset,
|
|
474
|
+
toolsDir = null,
|
|
464
475
|
instructionsPath = null,
|
|
465
476
|
inheritedInstructions = null,
|
|
466
477
|
force = false,
|
|
@@ -479,6 +490,14 @@ export async function installBundle({
|
|
|
479
490
|
? await canonicalInstructionFile(instructionsPath)
|
|
480
491
|
: null;
|
|
481
492
|
assertOutsideHome(skillsRoot, { yes, kind: 'skills directory' });
|
|
493
|
+
const toolsPath = toolsDir ?? (homeDir() ? join(homeDir(), '.axstack', 'tools') : null);
|
|
494
|
+
if (!toolsPath) throw new Error('HOME is unavailable; pass --tools-dir explicitly');
|
|
495
|
+
await assertNoSymlinksBelow('/', resolve(toolsPath));
|
|
496
|
+
const toolsRoot = await canonicalTargetDir(toolsPath);
|
|
497
|
+
if (withinRoot(toolsRoot, skillsRoot) || withinRoot(skillsRoot, toolsRoot)) {
|
|
498
|
+
throw new Error('tools directory overlaps the skills root; refusing');
|
|
499
|
+
}
|
|
500
|
+
assertOutsideHome(toolsRoot, { yes, kind: 'tools directory' });
|
|
482
501
|
if (instructionsFile) assertOutsideHome(instructionsFile, { yes, kind: 'instruction file' });
|
|
483
502
|
const claudePlan = await planInstallClaudeSettings({ claude, skillsRoot });
|
|
484
503
|
if (claudePlan.report.path) {
|
|
@@ -503,6 +522,7 @@ export async function installBundle({
|
|
|
503
522
|
);
|
|
504
523
|
}
|
|
505
524
|
const ownedFiles = prevManifest.files ?? {};
|
|
525
|
+
await guardArchifyRecord(prevManifest.archify, skillsRoot, yes);
|
|
506
526
|
const legacyProfiles = prevManifest.profiles;
|
|
507
527
|
const legacyProfileNote = Object.keys(legacyProfiles?.entries ?? {}).length > 0
|
|
508
528
|
? 'legacy Paseo profile provenance retained inert; see legacy cleanup guidance in docs/installation.md'
|
|
@@ -545,6 +565,11 @@ export async function installBundle({
|
|
|
545
565
|
// manifest entry resolves its destination, current bytes, and pristine
|
|
546
566
|
// flag through the same guard before Phase 2 mutates anything.
|
|
547
567
|
const desired = [];
|
|
568
|
+
const archifyTarget = await readOwnedTarget(skillsRoot, ARCHIFY_RECORD);
|
|
569
|
+
if (archifyTarget.current !== null &&
|
|
570
|
+
hashContent(archifyTarget.current) !== ownedFiles[ARCHIFY_RECORD]) {
|
|
571
|
+
throw new Error('archify record is edited or unowned; refusing to replace it');
|
|
572
|
+
}
|
|
548
573
|
for (const { rel, abs, content: inlineContent } of bundle.files) {
|
|
549
574
|
const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
|
|
550
575
|
const content = inlineContent ?? await readFile(abs);
|
|
@@ -562,6 +587,7 @@ export async function installBundle({
|
|
|
562
587
|
}
|
|
563
588
|
|
|
564
589
|
const bundleRels = new Set(bundle.files.map((file) => file.rel));
|
|
590
|
+
bundleRels.add(ARCHIFY_RECORD);
|
|
565
591
|
const stalePlan = [];
|
|
566
592
|
for (const rel of Object.keys(ownedFiles)) {
|
|
567
593
|
if (bundleRels.has(rel)) continue;
|
|
@@ -591,8 +617,23 @@ export async function installBundle({
|
|
|
591
617
|
const installedHashes = {};
|
|
592
618
|
const claudeWritten = [];
|
|
593
619
|
let instructionWritten = false;
|
|
620
|
+
let archify = null;
|
|
621
|
+
let archifyRelease = null;
|
|
622
|
+
const manifestFile = join(skillsRoot, '.axstack-manifest.json');
|
|
623
|
+
const manifestBefore = await readFile(manifestFile).catch((err) => {
|
|
624
|
+
if (err.code === 'ENOENT') return null;
|
|
625
|
+
throw err;
|
|
626
|
+
});
|
|
627
|
+
const manifestMode = manifestBefore === null ? null : (await stat(manifestFile)).mode & 0o777;
|
|
628
|
+
let manifestAttempted = false;
|
|
594
629
|
log('plan complete');
|
|
595
630
|
try {
|
|
631
|
+
archify = await provisionArchify({ toolsRoot, skillsRoot, writeAtomic, log });
|
|
632
|
+
const archifyRecord = archify.record ?? prevManifest.archify ?? null;
|
|
633
|
+
if (archifyRecord) {
|
|
634
|
+
desired.push({ rel: ARCHIFY_RECORD, ...archifyTarget,
|
|
635
|
+
content: Buffer.from(JSON.stringify(archifyRecord, null, 2) + '\n') });
|
|
636
|
+
}
|
|
596
637
|
if (instructionsFile) {
|
|
597
638
|
await assertFileSnapshot(instructionsFile, existingInstructionsRaw);
|
|
598
639
|
}
|
|
@@ -709,6 +750,12 @@ export async function installBundle({
|
|
|
709
750
|
)
|
|
710
751
|
: null;
|
|
711
752
|
|
|
753
|
+
if (prevManifest.archify && archifyRecord?.path !== prevManifest.archify.path) {
|
|
754
|
+
archifyRelease = await planArchifyRelease({
|
|
755
|
+
record: prevManifest.archify, skillsRoot, writeAtomic, log,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
manifestAttempted = true;
|
|
712
759
|
await writeManifest(skillsRoot, {
|
|
713
760
|
version: MANIFEST_VERSION,
|
|
714
761
|
files: installedHashes,
|
|
@@ -717,7 +764,9 @@ export async function installBundle({
|
|
|
717
764
|
path: claudePlan.settingsPath ?? boundClaudeSettingsPath,
|
|
718
765
|
},
|
|
719
766
|
instructions: nextInstructions,
|
|
767
|
+
archify: archifyRecord,
|
|
720
768
|
});
|
|
769
|
+
if (archifyRelease) await archifyRelease.finish();
|
|
721
770
|
if (legacyProfileNote || legacyInstructionNote) {
|
|
722
771
|
summary.notes = [
|
|
723
772
|
...(summary.notes ?? []),
|
|
@@ -751,6 +800,26 @@ export async function installBundle({
|
|
|
751
800
|
// Restore failures are collected and reported: a swallowed restore would
|
|
752
801
|
// leave ownership claims describing bytes that are not on disk.
|
|
753
802
|
const rollbackErrors = [];
|
|
803
|
+
if (manifestAttempted) {
|
|
804
|
+
try {
|
|
805
|
+
const current = await readFile(manifestFile).catch((readErr) => {
|
|
806
|
+
if (readErr.code === 'ENOENT') return null;
|
|
807
|
+
throw readErr;
|
|
808
|
+
});
|
|
809
|
+
if (manifestBefore === null && current !== null) await rm(manifestFile);
|
|
810
|
+
else if (manifestBefore !== null && current?.equals(manifestBefore) !== true) {
|
|
811
|
+
await writeAtomic(manifestFile, manifestBefore, { mode: manifestMode });
|
|
812
|
+
}
|
|
813
|
+
} catch (restoreErr) { rollbackErrors.push(`manifest: ${restoreErr.message}`); }
|
|
814
|
+
}
|
|
815
|
+
if (archifyRelease) {
|
|
816
|
+
try { await archifyRelease.rollback(); }
|
|
817
|
+
catch (restoreErr) { rollbackErrors.push(`old archify owner: ${restoreErr.message}`); }
|
|
818
|
+
}
|
|
819
|
+
if (archify) {
|
|
820
|
+
try { await archify.rollback(); }
|
|
821
|
+
catch (restoreErr) { rollbackErrors.push(`archify: ${restoreErr.message}`); }
|
|
822
|
+
}
|
|
754
823
|
for (const write of claudeWritten.reverse()) {
|
|
755
824
|
try {
|
|
756
825
|
if (write.before === null) await rm(write.path);
|
|
@@ -812,6 +881,7 @@ export async function uninstallBundle({
|
|
|
812
881
|
instructions: { path: null, hash: null },
|
|
813
882
|
};
|
|
814
883
|
const boundClaudeSettingsPath = manifest.claudeSettings?.path ?? null;
|
|
884
|
+
await guardArchifyRecord(manifest.archify, skillsRoot, yes);
|
|
815
885
|
const claudePlan = await planUninstallClaudeSettings({
|
|
816
886
|
claude,
|
|
817
887
|
skillsRoot,
|
|
@@ -920,6 +990,7 @@ export async function uninstallBundle({
|
|
|
920
990
|
const deletedFiles = [];
|
|
921
991
|
const claudeWritten = [];
|
|
922
992
|
let instructionWritten = false;
|
|
993
|
+
let archifyRelease = null;
|
|
923
994
|
try {
|
|
924
995
|
for (const rel of uninstallPlan) {
|
|
925
996
|
const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
|
|
@@ -958,6 +1029,11 @@ export async function uninstallBundle({
|
|
|
958
1029
|
}
|
|
959
1030
|
summary.claudeSettings = claudePlan.report;
|
|
960
1031
|
|
|
1032
|
+
const remainingArchify = ARCHIFY_RECORD in remainingFiles ? manifest.archify : null;
|
|
1033
|
+
if (manifest.archify && !remainingArchify) {
|
|
1034
|
+
archifyRelease = await planArchifyRelease({ record: manifest.archify, skillsRoot, writeAtomic, log });
|
|
1035
|
+
}
|
|
1036
|
+
|
|
961
1037
|
if (
|
|
962
1038
|
Object.keys(remainingFiles).length === 0 &&
|
|
963
1039
|
!hasLegacyProfiles &&
|
|
@@ -975,11 +1051,17 @@ export async function uninstallBundle({
|
|
|
975
1051
|
profiles: legacyProfiles,
|
|
976
1052
|
claudeSettings: { path: remainingClaudeSettingsPath },
|
|
977
1053
|
instructions: remainingInstructions,
|
|
1054
|
+
archify: remainingArchify,
|
|
978
1055
|
});
|
|
979
1056
|
}
|
|
1057
|
+
if (archifyRelease) await archifyRelease.finish();
|
|
980
1058
|
return summary;
|
|
981
1059
|
} catch (err) {
|
|
982
1060
|
const rollbackErrors = [];
|
|
1061
|
+
if (archifyRelease) {
|
|
1062
|
+
try { await archifyRelease.rollback(); }
|
|
1063
|
+
catch (restoreErr) { rollbackErrors.push(`archify owner: ${restoreErr.message}`); }
|
|
1064
|
+
}
|
|
983
1065
|
for (const write of claudeWritten.reverse()) {
|
|
984
1066
|
try {
|
|
985
1067
|
if (write.before === null) await rm(write.path);
|
package/src/manifest.js
CHANGED
|
@@ -80,6 +80,13 @@ function normalizeManifest(parsed) {
|
|
|
80
80
|
throw new Error('invalid ownership manifest: files must be an object of path hashes');
|
|
81
81
|
}
|
|
82
82
|
const rawClaudeSettings = parsed.claudeSettings ?? { path: null };
|
|
83
|
+
const archify = parsed.archify ?? null;
|
|
84
|
+
if (archify !== null && (typeof archify !== 'object' ||
|
|
85
|
+
typeof archify.path !== 'string' || !isAbsolute(archify.path) ||
|
|
86
|
+
!/^[a-f0-9]{40}$/.test(archify.sha) ||
|
|
87
|
+
!archify.path.endsWith(`/archify-${archify.sha}`))) {
|
|
88
|
+
throw new Error('invalid ownership manifest: archify must bind an absolute pinned copy');
|
|
89
|
+
}
|
|
83
90
|
if (
|
|
84
91
|
typeof rawClaudeSettings !== 'object' || rawClaudeSettings === null ||
|
|
85
92
|
Array.isArray(rawClaudeSettings) ||
|
|
@@ -118,6 +125,7 @@ function normalizeManifest(parsed) {
|
|
|
118
125
|
profiles: { path: null, preset: null, entries: { ...rawProfiles } },
|
|
119
126
|
claudeSettings: { path: rawClaudeSettings.path },
|
|
120
127
|
instructions,
|
|
128
|
+
...(archify ? { archify } : {}),
|
|
121
129
|
};
|
|
122
130
|
}
|
|
123
131
|
if (
|
|
@@ -137,6 +145,7 @@ function normalizeManifest(parsed) {
|
|
|
137
145
|
},
|
|
138
146
|
claudeSettings: { path: rawClaudeSettings.path },
|
|
139
147
|
instructions,
|
|
148
|
+
...(archify ? { archify } : {}),
|
|
140
149
|
};
|
|
141
150
|
}
|
|
142
151
|
throw new Error('invalid ownership manifest: profiles must bind a config path to id hashes');
|