@stravica-ai/digital-operator-pa 0.1.48 → 0.1.50

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.
Files changed (31) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +71 -2
  2. package/dist/engineering-pa-cli/src/pa/onboard.mjs +114 -0
  3. package/dist/engineering-pa-core/docs/commands/onboard.md +101 -27
  4. package/dist/engineering-pa-core/src/distill/cli.mjs +65 -0
  5. package/dist/engineering-pa-core/src/distill/health.mjs +486 -0
  6. package/dist/engineering-pa-core/src/distill/run.mjs +19 -0
  7. package/dist/engineering-pa-core/src/instance-root/find.mjs +104 -0
  8. package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +6 -1
  9. package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +6 -1
  10. package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +4 -1
  11. package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +6 -1
  12. package/dist/engineering-pa-core/src/scheduler/install.mjs +41 -4
  13. package/dist/engineering-pa-core/src/scheduler/list.mjs +34 -13
  14. package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -1
  15. package/dist/engineering-pa-core-extensions-atlassian/package.json +3 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +4 -2
  17. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +4 -2
  18. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +6 -2
  19. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +7 -3
  20. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +9 -3
  21. package/dist/engineering-pa-scaffold/src/generator.mjs +222 -11
  22. package/dist/engineering-pa-workspace/app/notes.html +1 -1
  23. package/dist/engineering-pa-workspace/app/project-detail.html +17 -1
  24. package/dist/engineering-pa-workspace/app/projects.html +1 -1
  25. package/dist/engineering-pa-workspace/app/tasks.html +1 -1
  26. package/dist/engineering-pa-workspace/src/install-daemon.mjs +2 -2
  27. package/dist/engineering-pa-workspace/src/repo-root.mjs +13 -100
  28. package/dist/engineering-pa-workspace/src/server.mjs +2 -2
  29. package/dist/engineering-pa-workspace/src/ws.mjs +79 -10
  30. package/docs/onboarding-solo.md +5 -5
  31. package/package.json +1 -1
@@ -11,7 +11,12 @@ import { fileURLToPath } from 'node:url';
11
11
  import { run as runDesk } from '../../../engineering-pa-core/src/desk/cli.mjs';
12
12
  import { PA_PACKAGE_NAME } from '../../../engineering-pa-core/src/config/pa-package-name.mjs';
13
13
  import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
14
+ import {
15
+ computeDistillationHealth,
16
+ renderHealthLine,
17
+ } from '../../../engineering-pa-core/src/distill/health.mjs';
14
18
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
19
+ import { doOnboard } from './onboard.mjs';
15
20
 
16
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
22
 
@@ -34,6 +39,7 @@ Usage:
34
39
  pa migrate-inspect [options] Host inspection for migration planning
35
40
  pa launch --agent=<n> --task=<id> [--dry-run]
36
41
  Boot a scoped agent instance bound to an identity and task
42
+ pa onboard <verb> Onboarding walkthrough state (complete)
37
43
  pa -h, --help Show this help
38
44
 
39
45
  Routed sub-CLIs (also available as standalone bins):
@@ -88,6 +94,8 @@ export async function main(argv) {
88
94
  return await runMigrateInspect(rest);
89
95
  case 'launch':
90
96
  return await doLaunch(rest);
97
+ case 'onboard':
98
+ return await doOnboard(rest);
91
99
  default:
92
100
  process.stderr.write(`[pa] unknown verb: ${verb}\n`);
93
101
  process.stderr.write(USAGE);
@@ -170,17 +178,47 @@ async function doStatus(opts = {}) {
170
178
  }
171
179
 
172
180
  // 3. Workspace reachability probe.
181
+ //
182
+ // 0.1.50 (item 12): report BOTH the running port (from `.ws.pid` when
183
+ // present) and the configured port when they differ, so the operator
184
+ // can see when the auto-probe moved the server onto a different port
185
+ // than preferences declare. Probe the running port when available,
186
+ // falling back to the configured port.
173
187
  out.write('\nWorkspace:\n');
174
- const probeUrl = `http://localhost:${workspacePort}/__api/identity`;
188
+ const runningPort = readRunningWorkspacePort(repoRoot);
189
+ const effectivePort = runningPort ?? workspacePort;
190
+ const probeUrl = `http://localhost:${effectivePort}/__api/identity`;
175
191
  const reachability = await probeWorkspace(probeUrl, opts.fetchImpl);
176
192
  out.write(` ${probeUrl}\n`);
193
+ if (runningPort !== null && runningPort !== workspacePort) {
194
+ out.write(` running port: ${runningPort} (configured: ${workspacePort})\n`);
195
+ }
177
196
  out.write(` status: ${reachability.status}`);
178
197
  if (reachability.detail) out.write(` (${reachability.detail})`);
179
198
  out.write('\n');
180
199
  // Workspace-not-running is OK — operators may run pa status before
181
200
  // starting the workspace server.
182
201
 
183
- // 4. Desk overview snapshot.
202
+ // 4. Distillation health — schedule installed?, last run, staging
203
+ // count. Load-bearing because the whole self-learning loop dies
204
+ // silently otherwise (the estate has already been bitten: on the
205
+ // WSD-laptop cold-install proof of 0.1.41, staged observations piled
206
+ // up for days without a scheduled sweep to distil them). Rendered on
207
+ // one line, prefixed `[ok]` or `[warn]` per the health module.
208
+ out.write('\nDistillation:\n');
209
+ try {
210
+ const health = await computeDistillationHealth({ repoRoot });
211
+ out.write(` ${renderHealthLine(health)}\n`);
212
+ // Do NOT flip allOk here — a fresh scaffold with no schedule
213
+ // installed (the exact moment /onboard is meant to run) would then
214
+ // exit 2 from pa status, which is the pre-install check for the
215
+ // scaffold itself. Warnings are visible in the line; exit code is
216
+ // reserved for structural failures (missing operator/).
217
+ } catch (e) {
218
+ out.write(` distillation health probe failed: ${e?.message || e}\n`);
219
+ }
220
+
221
+ // 5. Desk overview snapshot.
184
222
  out.write('\nOverview:\n');
185
223
  try {
186
224
  const code = await runDesk(['overview']);
@@ -196,6 +234,37 @@ async function doStatus(opts = {}) {
196
234
  return allOk ? 0 : 2;
197
235
  }
198
236
 
237
+ /**
238
+ * 0.1.50 (item 12): read the port the workspace is currently listening
239
+ * on from `.ws.pid`. Returns null when the pidfile is absent, corrupt,
240
+ * or the recorded PID is not alive. Never throws — callers fall back
241
+ * to the configured port silently.
242
+ *
243
+ * `.ws.pid` is written by `ws.mjs::cmdStart` on foreground spawn.
244
+ * Supervised installs (launchd / systemd) do NOT write it; when that is
245
+ * the case we still fall back to the configured port and the reachability
246
+ * probe hits the identity endpoint from there.
247
+ */
248
+ function readRunningWorkspacePort(repoRoot) {
249
+ try {
250
+ const pidPath = join(repoRoot, '.ws.pid');
251
+ if (!existsSync(pidPath)) return null;
252
+ const raw = JSON.parse(readFileSync(pidPath, 'utf8'));
253
+ if (!raw || typeof raw.pid !== 'number' || typeof raw.port !== 'number') return null;
254
+ // Verify the process is alive before trusting the port. A stale
255
+ // pidfile pointing at a dead process should not surface a
256
+ // running-port line.
257
+ try {
258
+ process.kill(raw.pid, 0);
259
+ } catch {
260
+ return null;
261
+ }
262
+ return raw.port;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
199
268
  function resolveWorkspacePort(repoRoot, opts = {}) {
200
269
  if (opts.workspacePort) return opts.workspacePort;
201
270
  try {
@@ -0,0 +1,114 @@
1
+ // onboard.mjs — `pa onboard` verb, currently just the `complete` subcommand.
2
+ //
3
+ // Adds a completion guard to the /onboard walkthrough. `.session/onboarding.json`
4
+ // carries per-step state; hand-editing it to set `completedAt` (the pre-0.1.50
5
+ // pattern) skipped the mandatory-step check. This verb refuses when a mandatory
6
+ // step is not `done`, names it, and quotes the remediation. When every mandatory
7
+ // step is done it stamps `completedAt` with an ISO string (UTC, second-precision)
8
+ // and preserves the 2-space indent of the file so subsequent diffs stay clean.
9
+ //
10
+ // Mandatory steps (as of 0.1.50): librarianSchedule. See
11
+ // packages/core/docs/commands/onboard.md §Completion.
12
+
13
+ import { readFile, writeFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+
16
+ import { findRepoRoot } from './repo-root.mjs';
17
+
18
+ const HELP = `pa onboard - Onboarding walkthrough state
19
+
20
+ Usage:
21
+ pa onboard complete Guard-checked completion of /onboard
22
+ pa onboard -h, --help Show this help
23
+
24
+ Subcommands:
25
+ complete
26
+ Reads .session/onboarding.json, refuses when any mandatory step (currently
27
+ librarianSchedule) is not 'done', otherwise stamps completedAt.
28
+
29
+ Exit codes:
30
+ 0 ok | 1 usage or a mandatory step is pending | 2 IO failure
31
+ `;
32
+
33
+ const MANDATORY_STEPS = [
34
+ {
35
+ id: 'librarianSchedule',
36
+ remediation: 'run /onboard and complete the librarianSchedule step (installs the mandatory distillation sweep)',
37
+ },
38
+ ];
39
+
40
+ export async function doOnboard(argv) {
41
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
42
+ process.stdout.write(HELP);
43
+ return 0;
44
+ }
45
+ const sub = argv[0];
46
+ const rest = argv.slice(1);
47
+ switch (sub) {
48
+ case 'complete':
49
+ return await onboardComplete(rest);
50
+ default:
51
+ process.stderr.write(`[pa onboard] unknown subcommand: ${sub}\n`);
52
+ process.stderr.write(HELP);
53
+ return 1;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Run the completion guard against `.session/onboarding.json`. Reads the
59
+ * file, checks every mandatory step is `done`, and stamps `completedAt`
60
+ * to the current UTC ISO timestamp (millisecond-truncated) when the
61
+ * gate passes.
62
+ *
63
+ * @param {string[]} argv reserved; no flags today
64
+ * @returns {Promise<number>} exit code
65
+ */
66
+ export async function onboardComplete(argv, deps = {}) {
67
+ const stderr = deps.stderr || process.stderr;
68
+ const stdout = deps.stdout || process.stdout;
69
+ const now = deps.now || (() => new Date().toISOString());
70
+ const repoRoot = deps.repoRoot
71
+ || process.env.PA_REPO_ROOT
72
+ || process.env.DAVE_REPO_ROOT
73
+ || await findRepoRoot(process.cwd());
74
+ if (!repoRoot) {
75
+ stderr.write('[pa onboard complete] could not resolve the operator repo root — set PA_REPO_ROOT or run from inside the instance.\n');
76
+ return 2;
77
+ }
78
+ const path = join(repoRoot, '.session', 'onboarding.json');
79
+ let raw;
80
+ try {
81
+ raw = await readFile(path, 'utf8');
82
+ } catch (e) {
83
+ if (e && e.code === 'ENOENT') {
84
+ stderr.write(`[pa onboard complete] no onboarding state at ${path} — run /onboard first.\n`);
85
+ return 1;
86
+ }
87
+ stderr.write(`[pa onboard complete] failed to read ${path}: ${e.message}\n`);
88
+ return 2;
89
+ }
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(raw);
93
+ } catch (e) {
94
+ stderr.write(`[pa onboard complete] onboarding state at ${path} is not valid JSON: ${e.message}\n`);
95
+ return 2;
96
+ }
97
+ const steps = parsed.steps || {};
98
+ const pending = [];
99
+ for (const step of MANDATORY_STEPS) {
100
+ if (steps[step.id] !== 'done') pending.push(step);
101
+ }
102
+ if (pending.length > 0) {
103
+ for (const step of pending) {
104
+ stderr.write(`[pa onboard complete] mandatory step '${step.id}' is not done (currently: ${JSON.stringify(steps[step.id] ?? null)})\n`);
105
+ stderr.write(` Remediation: ${step.remediation}\n`);
106
+ }
107
+ return 1;
108
+ }
109
+ parsed.completedAt = now();
110
+ const hadTrailingNewline = raw.endsWith('\n');
111
+ await writeFile(path, JSON.stringify(parsed, null, 2) + (hadTrailingNewline ? '\n' : ''));
112
+ stdout.write(`Onboarding marked complete at ${parsed.completedAt}\n`);
113
+ return 0;
114
+ }
@@ -28,17 +28,21 @@ Read `.session/onboarding.json` first. Shape:
28
28
  "version": 1,
29
29
  "startedAt": "<iso>",
30
30
  "steps": {
31
- "verifyRuntime": "pending" | "done" | "skipped",
32
- "verifyVersion": "pending" | "done" | "skipped",
33
- "profileIntake": "pending" | "done" | "skipped",
34
- "aliasOffer": "pending" | "done" | "skipped",
35
- "workspaceOffer":"pending" | "done" | "skipped",
36
- "atlassianOffer":"pending" | "done" | "skipped"
31
+ "verifyRuntime": "pending" | "done" | "skipped",
32
+ "verifyVersion": "pending" | "done" | "skipped",
33
+ "profileIntake": "pending" | "done" | "skipped",
34
+ "aliasOffer": "pending" | "done" | "skipped",
35
+ "workspaceOffer": "pending" | "done" | "skipped",
36
+ "librarianSchedule": "pending" | "done",
37
+ "atlassianOffer": "pending" | "done" | "skipped"
37
38
  },
38
39
  "completedAt": null | "<iso>"
39
40
  }
40
41
  ```
41
42
 
43
+ `librarianSchedule` never lands on `skipped`. It is a mandatory step and
44
+ onboarding does not complete until it is `done`.
45
+
42
46
  If the file is missing, the scaffold did not seed it — flag that and stop.
43
47
 
44
48
  If `completedAt` is set, tell the operator onboarding is already complete and
@@ -93,28 +97,92 @@ line to run; do not run it for them (their shell rc is not our file to write).
93
97
  ### 5. workspaceOffer
94
98
 
95
99
  Ask if they want the local workspace started now. On yes, run
96
- `pnpm exec pa workspace start` in the background and print the URL from
97
- `operator/config/preferences.json:workspacePort` (default 4200).
98
-
99
- If the start fails because the configured port is already in use (the
100
- operator has something else on 4200, or the previous session did not
101
- shut down cleanly), do NOT stop the walkthrough. Instead:
102
-
103
- 1. Pick a free port at or above 4210 (probe 4210, 4211, 4212 ... in
104
- order until one is free).
105
- 2. Retry `pnpm exec pa workspace start --port N` with that port.
106
- 3. On success, update `operator/config/preferences.json` with
107
- `"workspacePort": N` so subsequent sessions start on the same port
108
- without asking again. Preserve the rest of the file exactly, and
109
- round-trip through `JSON.parse` / `JSON.stringify` with a two-space
110
- indent to keep the on-disk shape stable.
111
- 4. Tell the operator you moved the workspace off 4200 and named the
112
- port that stuck.
100
+ `pnpm exec pa workspace start` in the background. The CLI auto-probes
101
+ for a free port when the configured one is busy (starting at
102
+ max(configured+1, 4210), up to 20 attempts) and persists the chosen
103
+ port to `operator/config/preferences.json:workspacePort`, so no manual
104
+ port dance is needed. Print the URL from the resulting preferences
105
+ file (the workspace start command's own output also names the chosen
106
+ port).
107
+
108
+ Pass `--port <N>` when the operator wants a specific port, or
109
+ `--no-probe` to keep the strict hard-fail behaviour instead of the
110
+ auto-probe.
113
111
 
114
112
  On no, note that `pnpm exec pa workspace start` is the verb whenever
115
113
  they want it.
116
114
 
117
- ### 6. atlassianOffer
115
+ ### 6. librarianSchedule (mandatory, no opt-out)
116
+
117
+ The distillation sweep is what turns staged observations under
118
+ `agents/<paName>/staging/` into knowledge docs under
119
+ `operator/knowledge/`. Without a scheduled sweep the whole self-learning
120
+ loop dies silently: staged notes pile up, no docs get promoted, and
121
+ `pa knowledge search` never sees the new material. The scaffold ships
122
+ the schedule file at `schedules/<paName>-distillation-sweep.json`; this
123
+ step installs it on the OS-native backend and fires one real run to
124
+ prove the wiring.
125
+
126
+ Do NOT ask the operator whether to do this. State what is happening in
127
+ one short paragraph:
128
+
129
+ > Installing the librarian distillation sweep so your Digital Operator
130
+ > keeps learning. It runs every four hours in the background, turns your
131
+ > staged observations into knowledge docs, and refreshes the retrieval
132
+ > graph so the new material is searchable straight away. This is the
133
+ > one piece of scheduling every solo instance needs.
134
+
135
+ Then run:
136
+
137
+ ```
138
+ pnpm exec scheduler install schedules/<paName>-distillation-sweep.json --force-host
139
+ ```
140
+
141
+ Substitute `<paName>` from `operator/config/preferences.json`. The
142
+ `--force-host` is deliberate. macOS hostnames flap between the DHCP
143
+ name and the configured name (Dex 2026-07-16 evidence), and a well-
144
+ formed refusal at first-run onboarding is the worst possible operator
145
+ experience.
146
+
147
+ Immediately after install, trigger one real sweep through the scheduler
148
+ backend itself (never a shell repro: a shell resolves `node` and PATH
149
+ differently and would hide the launchd/cron spawn behaviour the ledger
150
+ is meant to prove). On macOS/launchd:
151
+
152
+ ```
153
+ launchctl kickstart -k "gui/$(id -u)/com.<paName>.<paName>-distillation-sweep"
154
+ ```
155
+
156
+ On Linux/cron, invoke the exact command line the crontab entry carries,
157
+ from the same working directory, with a minimal PATH the cron job would
158
+ see:
159
+
160
+ ```
161
+ env -i HOME="$HOME" PATH=/usr/local/bin:/usr/bin:/bin \
162
+ bash -c 'cd <targetDir> && node node_modules/@stravica-ai/digital-operator-pa/bin/distill run'
163
+ ```
164
+
165
+ Wait for the run with a hard timeout of 6 minutes (a real sweep with
166
+ one `claude --print` subprocess is typically 60–120 seconds, but the
167
+ first-ever run on a cold cache can stretch). Poll
168
+ `.cache/distill/logs/runs.jsonl`. A completed run appends one JSON line
169
+ with `"status": "ok"` or `"status": "noop-empty-staging"`. Either
170
+ status is a successful proof: the pipeline ran end to end.
171
+
172
+ Mark this step `done` only when the ledger shows a completed run. If
173
+ the install fails, or the run times out, or the ledger records a
174
+ `fail-*` status, this step is NOT `done`, onboarding does NOT complete,
175
+ and the operator gets:
176
+
177
+ 1. The exact error message (from the scheduler CLI's stderr, or the
178
+ ledger's `reason` field, or a timeout notice).
179
+ 2. The one command to retry, verbatim:
180
+ `pnpm exec scheduler install schedules/<paName>-distillation-sweep.json --force-host`
181
+
182
+ Then stop. Do not silently skip to `atlassianOffer`. The whole
183
+ onboarding is blocked on this step.
184
+
185
+ ### 7. atlassianOffer
118
186
 
119
187
  Ask if they use Jira or Confluence. On no, mark skipped. On yes, walk them
120
188
  through:
@@ -131,6 +199,12 @@ identity each returns.
131
199
 
132
200
  ## Completion
133
201
 
134
- When every step is done or skipped, write `completedAt` and tell the operator
135
- onboarding is done. Give them one line: "You are ready. Ask me anything, or
136
- say `bring me up to speed` and I will run the standard session-boot briefing."
202
+ When every step is done or skipped, run `pnpm exec pa onboard complete`.
203
+ The verb refuses when any mandatory step (currently `librarianSchedule`)
204
+ is not `done`, naming the pending step and its remediation; otherwise
205
+ it stamps `completedAt` in `.session/onboarding.json` with a 2-space
206
+ indent. Never hand-edit the file.
207
+
208
+ Tell the operator onboarding is done. Give them one line: "You are
209
+ ready. Ask me anything, or say `bring me up to speed` and I will run
210
+ the standard session-boot briefing."
@@ -10,12 +10,26 @@ import { parseArgs } from 'node:util';
10
10
  import { defaultRepoRoot } from './prereqs.mjs';
11
11
  import { runDistill } from './run.mjs';
12
12
  import { defaultRunner } from './runner.mjs';
13
+ import {
14
+ buildHealthHookEnvelope,
15
+ computeDistillationHealth,
16
+ renderHealthBlock,
17
+ renderHealthLine,
18
+ } from './health.mjs';
13
19
 
14
20
  const USAGE = `distill — librarian distillation sweep runner
15
21
 
16
22
  Usage:
17
23
  distill run Run a real distillation sweep
18
24
  distill run --dry-run Print what would run, don't invoke claude
25
+ distill health Print one-line schedule + last-run + staging summary
26
+ distill health --json Print the full health object as JSON
27
+ distill health --block Print the multi-line markdown block
28
+ distill health --hook-envelope
29
+ Print a Claude Code SessionStart hook envelope
30
+ (JSON, {hookSpecificOutput: {...}}) — the shape
31
+ scaffolded into .claude/settings.json so the
32
+ summary is injected at session boot.
19
33
  distill -h, --help Show this help
20
34
 
21
35
  Exit codes:
@@ -106,6 +120,57 @@ async function route(argv) {
106
120
  process.exit(code ?? 0);
107
121
  }
108
122
 
123
+ if (verb === 'health') {
124
+ const { values } = parseArgs({
125
+ args: rest,
126
+ options: {
127
+ json: { type: 'boolean', default: false },
128
+ block: { type: 'boolean', default: false },
129
+ 'hook-envelope': { type: 'boolean', default: false },
130
+ help: { type: 'boolean', short: 'h', default: false },
131
+ },
132
+ strict: true,
133
+ });
134
+ if (values.help) {
135
+ process.stdout.write(USAGE);
136
+ process.exit(0);
137
+ }
138
+ const repoRoot = await defaultRepoRoot();
139
+ if (!repoRoot) {
140
+ // Preserve exit-code hygiene: no repo root == prerequisite missing.
141
+ // Still emit a valid hook envelope on --hook-envelope so a badly
142
+ // configured Claude session never sees invalid JSON on stdout.
143
+ const fallback = {
144
+ repoRoot: null, paName: null, cadenceHours: 4,
145
+ schedule: { present: false, path: null },
146
+ lastRun: null, stagingCount: { error: 'no repo root resolved' },
147
+ warnings: ['no operator repo root resolved from cwd (no `.git` ancestor and no DAVE_REPO_ROOT / PA_REPO_ROOT set)'],
148
+ ok: false,
149
+ generatedAt: new Date().toISOString(),
150
+ };
151
+ if (values['hook-envelope']) {
152
+ process.stdout.write(JSON.stringify(buildHealthHookEnvelope(fallback)) + '\n');
153
+ process.exit(0);
154
+ }
155
+ if (values.json) process.stdout.write(JSON.stringify(fallback, null, 2) + '\n');
156
+ else if (values.block) process.stdout.write(renderHealthBlock(fallback));
157
+ else process.stdout.write(renderHealthLine(fallback) + '\n');
158
+ process.exit(2);
159
+ }
160
+ const health = await computeDistillationHealth({ repoRoot });
161
+ if (values['hook-envelope']) {
162
+ process.stdout.write(JSON.stringify(buildHealthHookEnvelope(health)) + '\n');
163
+ // Hook path: always exit 0. Failing here would surface as a Claude
164
+ // Code hook error at every session boot, which is worse than a
165
+ // rendered warning block.
166
+ process.exit(0);
167
+ }
168
+ if (values.json) process.stdout.write(JSON.stringify(health, null, 2) + '\n');
169
+ else if (values.block) process.stdout.write(renderHealthBlock(health));
170
+ else process.stdout.write(renderHealthLine(health) + '\n');
171
+ process.exit(0);
172
+ }
173
+
109
174
  process.stderr.write(`[distill] unknown command: ${verb}\n`);
110
175
  process.stderr.write(USAGE);
111
176
  process.exit(1);