axstack 0.39.0 → 0.40.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 CHANGED
@@ -66,7 +66,7 @@ You make decisions at holds and substantial-spec approval.
66
66
  Follow [getting started](docs/getting-started.md) for the full first-task journey
67
67
  and [guides](docs/guides.md) for features, reviews, watches, debugging and releases.
68
68
 
69
- ## What you can do (19 skills)
69
+ ## What you can do (20 skills)
70
70
 
71
71
  | Layer | Skill | What it does |
72
72
  | --- | --- | --- |
@@ -85,6 +85,7 @@ and [guides](docs/guides.md) for features, reviews, watches, debugging and relea
85
85
  | Operate | [axstack-watch](skills/axstack-watch/SKILL.md) | Observe or maintain an existing PR within its authority. |
86
86
  | Operate | [axstack-cleanup](skills/axstack-cleanup/SKILL.md) | Retire eligible completed agent resources. |
87
87
  | Operate | [axstack-relay](skills/axstack-relay/SKILL.md) | Send an explicit message or authorized notification. |
88
+ | Operate | [axstack-publish](skills/axstack-publish/SKILL.md) | Publish one checked Explain article in the explicitly authorized private or public mode. |
88
89
  | Understand | [axstack-research](skills/axstack-research/SKILL.md) | Answer one bounded question with sources. |
89
90
  | Understand | [axstack-scout](skills/axstack-scout/SKILL.md) | Gather codebase evidence for an agent across several areas. |
90
91
  | Understand | [axstack-explain](skills/axstack-explain/SKILL.md) | Explain a system and separate known behavior from gaps. |
@@ -130,7 +131,8 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
130
131
  database. The exceptions are exactly two opt-in systemd user timers:
131
132
  `axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
132
133
  `axstack-single-server` removes duplicate `t3 serve` process trees for one
133
- base dir while keeping the recorded server. Both run stateless scripts, keep
134
+ base dir while keeping the ownership-lock holder, otherwise the recorded
135
+ server. Both run stateless scripts, keep
134
136
  no workflow state and launch no agent; see
135
137
  [New-chat default sync](docs/host-operations.md#new-chat-default-sync) and
136
138
  [Single T3 server](docs/host-operations.md#single-t3-server).
@@ -315,12 +315,21 @@ comes from its `--base-dir <dir>` or `--base-dir=<dir>` flag, then its own
315
315
  `T3CODE_HOME`, then its own `$HOME/.t3`, reading the latter values from that
316
316
  process's environment. An unreadable or missing environment without an explicit
317
317
  flag, or missing base-dir values, makes the whole run hold and kill nothing.
318
- It keeps the pid recorded in `<base>/userdata/server-runtime.json`.
318
+ Both the target and server base dirs expand `~` using their own HOME, resolve
319
+ relative paths against `/proc/<pid>/cwd`, and remove trailing slashes and dot
320
+ segments before comparison. Unresolvable server paths hold the run.
321
+ It keeps the unique server with an open fd to `<base>/userdata/server-owner.sqlite`
322
+ (matching lexical and realpath-resolved userdata paths, and checking the main pid
323
+ before descendants), holds on multiple holders or a known
324
+ holder alongside unreadable competing fds, and falls back to `server-runtime.json`
325
+ when no holder is found, including when fd directories are unreadable.
319
326
  With zero or one known matching server it does nothing; with duplicates but no
320
- valid readable keeper it holds. Duplicate servers must have started strictly
321
- before the keeper, comparing field 22 (`starttime`) of `/proc/<pid>/stat`.
322
- An unknown start time or any duplicate started at or after the keeper holds
323
- the whole run, preserving a possible starting replacement. The script also
327
+ valid readable keeper it holds. Servers are ordered by field 22 (`starttime`)
328
+ of `/proc/<pid>/stat`. A duplicate started at or after the keeper holds the
329
+ whole run while younger than 120 seconds, preserving a possible starting
330
+ replacement. Once all such duplicates reach the grace, the selected live
331
+ keeper wins and cleanup proceeds. Age is `/proc/uptime` minus start ticks / 100;
332
+ unknown uptime or start time still holds the run. The script also
324
333
  holds if its own pid or an ancestor appears in the planned victim set.
325
334
  Otherwise it snapshots descendants before killing each duplicate server first,
326
335
  then its children with SIGKILL. The keeper and its descendants are protected.
@@ -333,12 +342,14 @@ bun ~/.agents/skills/axstack/scripts/single-server.js --dry-run
333
342
  ```
334
343
 
335
344
  `--base-dir <dir>` selects a different base dir; `--proc-root <dir>` supplies
336
- a process-tree fixture (default `/proc`). `--help` lists the flags. Decisions
345
+ a process-tree fixture (default `/proc`). `--grace-seconds N` overrides the
346
+ 120-second grace with a non-negative integer. `--help` lists the flags. Decisions
337
347
  are `unchanged`, `hold`, `would-kill` or `killed`, with reason, keeper, matching
338
348
  server pids and killed pids (proposed victims in dry-run). Non-ESRCH kill failures
339
- add their pids to `failed`; remaining kills continue, successful kills still
340
- notify if configured, and the command exits 1. Exit 0 includes holds;
341
- exit 1 reports invalid input, discovery, kill or notification failure.
349
+ add their pids to `failed`; remaining kills continue, kill attempts still
350
+ notify if configured (including when all fail), and the command exits 1. Exit 0 includes holds;
351
+ exit 1 reports invalid input, discovery, kill or notification failure. A failed
352
+ hold-alert notification retains the hold reason and adds `notify: "failed"`.
342
353
 
343
354
  Under host-mutation authority, copy both assets and enable the timer:
344
355
 
@@ -357,7 +368,7 @@ timer starts one minute after user-manager startup and repeats every minute.
357
368
  Override Bun/skills paths or add `--base-dir` through the service's `ExecStart`
358
369
  drop-in, clearing the existing `ExecStart` first.
359
370
 
360
- To notify only after a kill, use `systemctl --user edit axstack-single-server.service`
371
+ To notify after cleanup attempts and persistent holds, use `systemctl --user edit axstack-single-server.service`
361
372
  and add a per-host command, then reload:
362
373
 
363
374
  ```ini
@@ -369,10 +380,15 @@ The VPS has Hermes locally; other hosts can use
369
380
  `Environment="AXSTACK_SINGLE_SERVER_NOTIFY=ssh '<relay-host>' hermes send --to telegram --quiet"`.
370
381
  Replace `<relay-host>` with the authorized gateway's hostname.
371
382
  The script runs the command via `sh -c`, sending a short message on stdin with
372
- hostname, kept pid and killed pids. It notes that runs started by the killed
383
+ hostname, kept pid, killed pids and failed survivor pids. It notes that runs started by the killed
373
384
  server may remain `running` and need `t3_thread_interrupt`. Notification failure
374
- exits 1 after the kills; command output is suppressed. Dry-run, unchanged and
375
- hold decisions never notify. Verify delivery under the host's notification authority.
385
+ exits 1; command output is suppressed. A hold with two or more matching servers
386
+ also notifies when the youngest server's known age is at least 600 seconds and
387
+ under 780 seconds. Its message includes host, hold reason, keeper, server pids,
388
+ and that duplicates are still running. This stateless window produces at most
389
+ about three alerts at the one-minute cadence; it records no notification state.
390
+ Unknown ages suppress hold alerts. Dry-run and unchanged decisions never
391
+ notify. Verify delivery under the host's notification authority.
376
392
 
377
393
  Disable with `systemctl --user disable --now axstack-single-server.timer` and
378
394
  stop any active one-shot with `systemctl --user stop axstack-single-server.service`.
@@ -90,8 +90,9 @@ Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
90
90
  `resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
91
91
  `pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
92
92
  `sync-default.js [--settings <path>] [--dry-run]` (New-chat default; `AXSTACK_SYNC_POOL` opts into cross-driver rotation).
93
- `single-server.js [--base-dir <dir>] [--proc-root <dir>] [--dry-run]` removes
94
- duplicate `t3 serve` process trees while keeping the recorded runtime pid.
93
+ `single-server.js [--base-dir <dir>] [--proc-root <dir>] [--grace-seconds N] [--dry-run]` removes
94
+ duplicate `t3 serve` process trees while keeping the ownership-lock holder,
95
+ otherwise the recorded runtime pid.
95
96
  `account-usage.js` shares usage retrieval and the five-minute cache between
96
97
  picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
97
98
  installation copies them as assets and never enables either timer.
package/docs/workflows.md CHANGED
@@ -22,6 +22,9 @@ immediately before dispatch or account re-selection.
22
22
 
23
23
  Direct routes need no spec ceremony:
24
24
 
25
+ - [axstack-publish](../skills/axstack-publish/SKILL.md) publishes one Explain
26
+ HTML article in one explicitly authorized private or public mode through the
27
+ driver, after final-byte checks, independent review and live verification.
25
28
  - [axstack-verify](../skills/axstack-verify/SKILL.md) routes verification-skill
26
29
  creation and maintenance through Implement.
27
30
  A single-behavior check uses an existing `verify-<app>` skill or UI verification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.39.0",
3
+ "version": "0.40.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",
@@ -1,17 +1,40 @@
1
1
  import * as fs from 'node:fs';
2
2
 
3
+ function normalizeBase(procRoot, pid, base, home) {
4
+ if (!base) return null;
5
+ if (base === '~' || base.startsWith('~/')) {
6
+ if (!home) return null;
7
+ base = home + base.slice(1);
8
+ }
9
+ if (!base.startsWith('/')) {
10
+ let cwd;
11
+ try { cwd = fs.readlinkSync(`${procRoot}/${pid}/cwd`); }
12
+ catch { return null; }
13
+ if (!cwd.startsWith('/')) return null;
14
+ base = `${cwd}/${base}`;
15
+ }
16
+ const parts = [];
17
+ for (const part of base.split('/')) {
18
+ if (part === '..') parts.pop();
19
+ else if (part && part !== '.') parts.push(part);
20
+ }
21
+ return `/${parts.join('/')}`;
22
+ }
23
+
3
24
  function serverBase(procRoot, pid, argv) {
4
25
  let base;
5
26
  for (let i = 2; i < argv.length; i++) {
6
27
  if (argv[i] === '--base-dir') base = argv[++i];
7
28
  else if (argv[i].startsWith('--base-dir=')) base = argv[i].slice('--base-dir='.length);
8
29
  }
9
- if (base !== undefined) return base || null;
30
+ let home;
10
31
  try {
11
32
  const environ = fs.readFileSync(`${procRoot}/${pid}/environ`, 'utf8').split('\0');
12
33
  const value = (key) => environ.find((entry) => entry.startsWith(`${key}=`))?.slice(key.length + 1);
13
- return value('T3CODE_HOME') || (value('HOME') ? `${value('HOME')}/.t3` : null);
14
- } catch { return null; }
34
+ home = value('HOME');
35
+ if (base === undefined) base = value('T3CODE_HOME') || (home ? `${home}/.t3` : null);
36
+ } catch { /* An absolute argv base does not need the process environment. */ }
37
+ return normalizeBase(procRoot, pid, base, home);
15
38
  }
16
39
 
17
40
  function discover(procRoot, uid, baseDir) {
@@ -49,7 +72,36 @@ function descendants(pid, processes) {
49
72
  return [...found];
50
73
  }
51
74
 
52
- function replacementHold(procRoot, servers, keeper) {
75
+ function ownerFd(procRoot, pid, ownerFiles) {
76
+ let unknown = false;
77
+ let fds;
78
+ try { fds = fs.readdirSync(`${procRoot}/${pid}/fd`); }
79
+ catch (error) { return ['ENOENT', 'ESRCH'].includes(error.code) ? false : null; }
80
+ for (const fd of fds) {
81
+ try { if (ownerFiles.includes(fs.readlinkSync(`${procRoot}/${pid}/fd/${fd}`))) return true; }
82
+ catch (error) { if (!['ENOENT', 'ESRCH'].includes(error.code)) unknown = true; }
83
+ }
84
+ return unknown ? null : false;
85
+ }
86
+
87
+ function serverOwner(procRoot, pid, processes, ownerFiles) {
88
+ const main = ownerFd(procRoot, pid, ownerFiles);
89
+ if (main === true) return true;
90
+ let unknown = main === null;
91
+ for (const child of descendants(pid, processes)) {
92
+ const holds = ownerFd(procRoot, child, ownerFiles);
93
+ if (holds === true) return true;
94
+ if (holds === null) unknown = true;
95
+ }
96
+ return unknown ? null : false;
97
+ }
98
+
99
+ function serverTimes(procRoot, servers) {
100
+ let uptime = null;
101
+ try {
102
+ const value = fs.readFileSync(`${procRoot}/uptime`, 'utf8').trim().split(/\s+/)[0];
103
+ if (/^\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))) uptime = Number(value);
104
+ } catch { /* Unknown uptime never authorizes a kill. */ }
53
105
  const starts = new Map(servers.map((pid) => {
54
106
  let start = null;
55
107
  try {
@@ -61,8 +113,16 @@ function replacementHold(procRoot, servers, keeper) {
61
113
  } catch { /* Unknown age never authorizes a kill. */ }
62
114
  return [pid, start];
63
115
  }));
116
+ const ages = new Map([...starts].map(([pid, start]) => [pid,
117
+ start === null || uptime === null ? null : uptime - Number(start) / 100]));
118
+ return { starts, ages, uptime };
119
+ }
120
+
121
+ function replacementHold({ starts, ages, uptime }, servers, keeper, graceSeconds) {
64
122
  if ([...starts.values()].includes(null)) return 'unknown-start-time';
65
- if (servers.some((pid) => pid !== keeper && starts.get(pid) >= starts.get(keeper))) return 'possible-replacement';
123
+ if (uptime === null) return 'unknown-uptime';
124
+ if (servers.some((pid) => pid !== keeper && starts.get(pid) >= starts.get(keeper)
125
+ && ages.get(pid) < graceSeconds)) return 'possible-replacement';
66
126
  return null;
67
127
  }
68
128
 
@@ -77,35 +137,59 @@ export async function run(args = [], {
77
137
  let baseDir = `${env.HOME}/.t3`;
78
138
  let procRoot = '/proc';
79
139
  let dryRun = false;
140
+ let graceSeconds = 120;
80
141
  const seen = new Set();
81
142
  for (let i = 0; i < args.length; i++) {
82
143
  const flag = args[i];
83
144
  if (flag === '--help' && args.length === 1) {
84
- output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--dry-run] [--help]\n'
145
+ output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--grace-seconds N] [--dry-run] [--help]\n'
85
146
  + 'Defaults: $HOME/.t3 and /proc. SIGKILL duplicate t3 serve servers and their descendants;\n'
86
- + 'keep the runtime pid. --dry-run reports only. AXSTACK_SINGLE_SERVER_NOTIFY runs via sh -c after kills.');
147
+ + 'prefer the unique server-owner.sqlite fd holder, otherwise the runtime pid.\n'
148
+ + 'Newer servers get 120 seconds grace (N: non-negative integer).\n'
149
+ + '--dry-run reports only. AXSTACK_SINGLE_SERVER_NOTIFY runs via sh -c after kills,\n'
150
+ + 'or for holds with 2+ servers whose youngest age is 600 to under 780 seconds.');
87
151
  return 0;
88
152
  }
89
153
  if (seen.has(flag)) throw new Error('duplicate flag');
90
154
  seen.add(flag);
91
155
  if (flag === '--dry-run') dryRun = true;
92
- else if (['--base-dir', '--proc-root'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
156
+ else if (['--base-dir', '--proc-root', '--grace-seconds'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
93
157
  const value = args[++i];
94
158
  if (flag === '--base-dir') baseDir = value;
95
- else procRoot = value;
159
+ else if (flag === '--proc-root') procRoot = value;
160
+ else {
161
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error('invalid grace');
162
+ graceSeconds = Number(value);
163
+ }
96
164
  } else throw new Error('invalid flag');
97
165
  }
98
166
  if (!env.HOME) throw new Error('missing home');
167
+ baseDir = normalizeBase(procRoot, process.pid, baseDir, env.HOME);
168
+ if (baseDir === null) throw new Error('unresolved base dir');
99
169
  failure = 'scan-failed';
100
170
  const { processes, servers, unknownBase } = discover(procRoot, uid, baseDir);
101
171
  decision.servers = servers;
102
- let runtimePid;
103
- try { runtimePid = JSON.parse(fs.readFileSync(`${baseDir}/userdata/server-runtime.json`, 'utf8')).pid; }
104
- catch { /* Missing, unreadable or malformed runtime data is never authority to kill. */ }
105
- if (Number.isSafeInteger(runtimePid) && servers.includes(runtimePid)) decision.keeper = runtimePid;
172
+ const ownerFiles = [`${baseDir}/userdata/server-owner.sqlite`];
173
+ try { ownerFiles.push(`${fs.realpathSync(`${baseDir}/userdata`)}/server-owner.sqlite`); }
174
+ catch { /* Keep lexical matching when the userdata directory cannot be resolved. */ }
175
+ const owners = servers.map((pid) => [pid,
176
+ serverOwner(procRoot, pid, processes, ownerFiles)]);
177
+ const holders = owners.filter(([, holds]) => holds === true).map(([pid]) => pid);
178
+ const ownerHold = holders.length > 1 ? 'multiple-owner-locks'
179
+ : holders.length === 1 && owners.some(([, holds]) => holds === null) ? 'unknown-owner-lock' : null;
180
+ if (holders.length === 1 && !ownerHold) decision.keeper = holders[0];
181
+ else if (!holders.length) {
182
+ // Legacy servers have no owner fd; unreadable fds alone retain the runtime fallback.
183
+ let runtimePid;
184
+ try { runtimePid = JSON.parse(fs.readFileSync(`${baseDir}/userdata/server-runtime.json`, 'utf8')).pid; }
185
+ catch { /* Missing, unreadable or malformed runtime data is never authority to kill. */ }
186
+ if (Number.isSafeInteger(runtimePid) && servers.includes(runtimePid)) decision.keeper = runtimePid;
187
+ }
188
+ const times = serverTimes(procRoot, servers);
106
189
  const ageHold = !unknownBase && servers.length > 1 && decision.keeper !== null
107
- ? replacementHold(procRoot, servers, decision.keeper) : null;
108
- if (unknownBase) decision.reason = 'unknown-base-dir';
190
+ ? replacementHold(times, servers, decision.keeper, graceSeconds) : null;
191
+ if (ownerHold) decision.reason = ownerHold;
192
+ else if (unknownBase) decision.reason = 'unknown-base-dir';
109
193
  else if (servers.length < 2) decision = { ...decision, action: 'unchanged', reason: 'single-server' };
110
194
  else if (decision.keeper === null) decision.reason = 'no-valid-keeper';
111
195
  else if (ageHold) decision.reason = ageHold;
@@ -142,19 +226,34 @@ export async function run(args = [], {
142
226
  }
143
227
  if (decision.failed?.length) decision.reason = 'kill-failed';
144
228
  else if (!decision.killed.length) decision = { ...decision, action: 'unchanged', reason: 'no-victims' };
145
- if (decision.killed.length && env.AXSTACK_SINGLE_SERVER_NOTIFY) {
146
- failure = 'notify-failed';
147
- const message = `${hostname()}: kept pid ${decision.keeper}; killed pids ${decision.killed.join(', ')}. `
148
- + 'Runs started by the killed server may stay running and need t3_thread_interrupt.\n';
149
- const child = spawn(['sh', '-c', env.AXSTACK_SINGLE_SERVER_NOTIFY], {
150
- stdin: new Blob([message]), stdout: 'ignore', stderr: 'ignore', env,
151
- });
152
- if (await child.exited !== 0) throw new Error('notification failed');
229
+ }
230
+ }
231
+ if (!dryRun && env.AXSTACK_SINGLE_SERVER_NOTIFY) {
232
+ let message;
233
+ failure = 'notify-failed';
234
+ if (decision.killed.length || decision.failed?.length) {
235
+ message = `${hostname()}: kept pid ${decision.keeper}; killed pids ${decision.killed.join(', ') || 'none'}. `
236
+ + 'Runs started by the killed server may stay running and need t3_thread_interrupt.\n';
237
+ if (decision.failed?.length) message += `Failed pids ${decision.failed.join(', ')} are still running.\n`;
238
+ } else if (decision.action === 'hold' && servers.length > 1) {
239
+ const ages = [...times.ages.values()];
240
+ const youngest = ages.every((age) => age !== null && age >= 0) ? Math.min(...ages) : null;
241
+ if (youngest !== null && youngest >= 600 && youngest < 780) {
242
+ message = `${hostname()}: hold ${decision.reason}; keeper ${decision.keeper ?? 'none'}; `
243
+ + `servers ${servers.join(', ')}; duplicates are still running.\n`;
153
244
  }
154
245
  }
246
+ if (message) {
247
+ const child = spawn(['sh', '-c', env.AXSTACK_SINGLE_SERVER_NOTIFY], {
248
+ stdin: new Blob([message]), stdout: 'ignore', stderr: 'ignore', env,
249
+ });
250
+ if (await child.exited !== 0) throw new Error('notification failed');
251
+ }
155
252
  }
156
253
  } catch {
157
- decision.reason = failure;
254
+ if (failure === 'notify-failed' && decision.action === 'hold' && !decision.failed?.length) {
255
+ decision.notify = 'failed';
256
+ } else decision.reason = failure;
158
257
  code = 1;
159
258
  }
160
259
  output(JSON.stringify(decision));
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: axstack-publish
3
+ description: When the user requests publication of an Explain HTML article, use axstack-publish to verify and publish that article in the explicitly authorized mode.
4
+ ---
5
+
6
+ # Publish
7
+
8
+ Publish an existing Explain HTML article through the notes CLI on the configured publish host.
9
+ The current T3 driver owns publication; workers return checked artifacts.
10
+ Load [Standing contracts](../axstack/references/contracts.md) and follow its
11
+ lifecycle pointers. Keep page bytes, checks and receipts in private run evidence.
12
+
13
+ ## 1. Bind the request
14
+
15
+ Read host-managed `~/.config/axstack/publish.json` containing string fields
16
+ `sshHost`, `publicOrigin` and `docsOrigin`.
17
+
18
+ ```json
19
+ {"sshHost":"<publish host>","publicOrigin":"<public HTTPS origin>","docsOrigin":"<docs HTTPS origin>"}
20
+ ```
21
+
22
+ Validate `sshHost` as `^[A-Za-z0-9][A-Za-z0-9.-]*$` and both origins as HTTPS
23
+ URL origins with no credentials, path, query or fragment.
24
+ Hold publication if the config is missing or invalid.
25
+ Never guess a publish host or either origin.
26
+ Keep these routing values host-managed; this config grants no publication authority.
27
+ Identify the Explain page by its exact bytes, byte count and SHA-256.
28
+ State the article, slug, mode (`private` or `public`) and target URL.
29
+ Public targets `<publicOrigin>/<slug>/`; private targets the
30
+ SlashDocs document URL at `docsOrigin` returned by the CLI.
31
+ For a new private document, state that origin and slug before publication and
32
+ record the allocated URL afterwards.
33
+ Require explicit authority for exactly one article and one mode per request.
34
+ Promotion, unpublish, reader changes and grant changes each need their own request.
35
+ Local creation or an earlier publication supplies no new publication authority.
36
+ If the request leaves the article or mode unclear, hold publication for clarification.
37
+
38
+ ## 2. Prepare the final artifact
39
+
40
+ For public mode, run the [Explain redaction check](../axstack-explain/SKILL.md#3-verify-and-deliver)
41
+ to remove private paths, IDs, credentials, prompts, messages and internal records.
42
+ For public mode, run Explain's [self-contained asset check](../axstack-explain/references/inline-pages.md#check-the-exact-bytes).
43
+ Reuse Explain's [page rules](../axstack-explain/references/inline-pages.md#page-rules)
44
+ and [Visual QA](../axstack-explain/references/visual-qa.md) for checks and verifier routing.
45
+
46
+ Add a standalone light/dark theme with inline defaults and `prefers-color-scheme`
47
+ so the article works outside T3; honor an explicit user theme request.
48
+ Theme adaptation changes bytes: save `final.html`, its byte count and SHA-256,
49
+ then rerun the referenced Explain page checks on these final bytes.
50
+ Require a fresh independent `axstack-explainer-review` on the final bytes.
51
+ Preview these exact bytes with Playwright MCP at 728px dark and 360px light;
52
+ record both screenshots and the Explain QA results.
53
+ Any subsequent byte change invalidates checks and review: repeat this step.
54
+ Hold publication while any required check or review is missing or failed.
55
+
56
+ ## 3. Publish from the driver
57
+
58
+ Publish only from the driver thread.
59
+ Use stdin only with no TTY and at most
60
+ 5,000,000 bytes (5 MB) of final HTML:
61
+
62
+ ```bash
63
+ ssh <publish host> notes publish --mode <mode> --slug <slug> < final.html
64
+ ```
65
+
66
+ Use the validated `sshHost`, validate the slug as `^[a-z0-9][a-z0-9-]{0,63}$`
67
+ and select one literal mode (`private` or `public`).
68
+ Use safely quoted arguments, never interpolate unchecked input into a shell.
69
+ Require exit zero and one JSON line containing `url`, `doc`, `version`, `sha256`.
70
+ Compare its SHA with the final artifact; a mismatch or ambiguous result holds
71
+ success reporting until reconciled with `ssh <publish host> notes status --slug <slug>`.
72
+ Require the returned URL's origin to match the configured origin for that mode.
73
+ Retain phase errors and pending state as unresolved evidence.
74
+
75
+ ## 4. Prove the live result and return
76
+
77
+ Verify the live URL in a real browser using Playwright MCP against the final SHA-256.
78
+ For public mode, verify an anonymous browser reads exact response bytes at
79
+ `<publicOrigin>/<slug>/` whose SHA-256 matches the final artifact.
80
+ Hash the response body bytes, rather than DOM serialization or rendered text.
81
+ For private mode, verify anonymous navigation reaches the SlashDocs sign-in page
82
+ leading to Dex and `notes status --slug <slug>` reports `published: true`, the
83
+ live version and SHA-256 that matches the final bytes.
84
+ Compare status's private version with the publish result; null live metadata
85
+ or a pending operation leaves verification unproven.
86
+ Reader-side reads are proven only by the separately authorized AC23 end-to-end
87
+ run; an anonymous redirect and CLI metadata do not prove a reader grant.
88
+ Return URL, version and SHA-256 with the live verification evidence or its hold.
89
+ Tell the user when `overwrote_external_edit` is true: publication replaced an
90
+ external edit, retained as an earlier SlashDocs version.
91
+
92
+ Agents never handle reader passwords.
93
+ Agents never run `notes reader show` or allocate a TTY (`-t` or `-tt` on SSH).
94
+ Reader and grant commands require their own request and secret-free non-TTY output.
95
+ The owner shares reader credentials separately from the article link.