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

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.
@@ -11,6 +11,10 @@ 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';
15
19
 
16
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -180,7 +184,26 @@ async function doStatus(opts = {}) {
180
184
  // Workspace-not-running is OK — operators may run pa status before
181
185
  // starting the workspace server.
182
186
 
183
- // 4. Desk overview snapshot.
187
+ // 4. Distillation health — schedule installed?, last run, staging
188
+ // count. Load-bearing because the whole self-learning loop dies
189
+ // silently otherwise (the estate has already been bitten: on the
190
+ // WSD-laptop cold-install proof of 0.1.41, staged observations piled
191
+ // up for days without a scheduled sweep to distil them). Rendered on
192
+ // one line, prefixed `[ok]` or `[warn]` per the health module.
193
+ out.write('\nDistillation:\n');
194
+ try {
195
+ const health = await computeDistillationHealth({ repoRoot });
196
+ out.write(` ${renderHealthLine(health)}\n`);
197
+ // Do NOT flip allOk here — a fresh scaffold with no schedule
198
+ // installed (the exact moment /onboard is meant to run) would then
199
+ // exit 2 from pa status, which is the pre-install check for the
200
+ // scaffold itself. Warnings are visible in the line; exit code is
201
+ // reserved for structural failures (missing operator/).
202
+ } catch (e) {
203
+ out.write(` distillation health probe failed: ${e?.message || e}\n`);
204
+ }
205
+
206
+ // 5. Desk overview snapshot.
184
207
  out.write('\nOverview:\n');
185
208
  try {
186
209
  const code = await runDesk(['overview']);
@@ -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
@@ -114,7 +118,77 @@ shut down cleanly), do NOT stop the walkthrough. Instead:
114
118
  On no, note that `pnpm exec pa workspace start` is the verb whenever
115
119
  they want it.
116
120
 
117
- ### 6. atlassianOffer
121
+ ### 6. librarianSchedule (mandatory, no opt-out)
122
+
123
+ The distillation sweep is what turns staged observations under
124
+ `agents/<paName>/staging/` into knowledge docs under
125
+ `operator/knowledge/`. Without a scheduled sweep the whole self-learning
126
+ loop dies silently: staged notes pile up, no docs get promoted, and
127
+ `pa knowledge search` never sees the new material. The scaffold ships
128
+ the schedule file at `schedules/<paName>-distillation-sweep.json`; this
129
+ step installs it on the OS-native backend and fires one real run to
130
+ prove the wiring.
131
+
132
+ Do NOT ask the operator whether to do this. State what is happening in
133
+ one short paragraph:
134
+
135
+ > Installing the librarian distillation sweep so your Digital Operator
136
+ > keeps learning. It runs every four hours in the background, turns your
137
+ > staged observations into knowledge docs, and refreshes the retrieval
138
+ > graph so the new material is searchable straight away. This is the
139
+ > one piece of scheduling every solo instance needs.
140
+
141
+ Then run:
142
+
143
+ ```
144
+ pnpm exec scheduler install schedules/<paName>-distillation-sweep.json --force-host
145
+ ```
146
+
147
+ Substitute `<paName>` from `operator/config/preferences.json`. The
148
+ `--force-host` is deliberate. macOS hostnames flap between the DHCP
149
+ name and the configured name (Dex 2026-07-16 evidence), and a well-
150
+ formed refusal at first-run onboarding is the worst possible operator
151
+ experience.
152
+
153
+ Immediately after install, trigger one real sweep through the scheduler
154
+ backend itself (never a shell repro: a shell resolves `node` and PATH
155
+ differently and would hide the launchd/cron spawn behaviour the ledger
156
+ is meant to prove). On macOS/launchd:
157
+
158
+ ```
159
+ launchctl kickstart -k "gui/$(id -u)/com.<paName>.<paName>-distillation-sweep"
160
+ ```
161
+
162
+ On Linux/cron, invoke the exact command line the crontab entry carries,
163
+ from the same working directory, with a minimal PATH the cron job would
164
+ see:
165
+
166
+ ```
167
+ env -i HOME="$HOME" PATH=/usr/local/bin:/usr/bin:/bin \
168
+ bash -c 'cd <targetDir> && node node_modules/@stravica-ai/digital-operator-pa/bin/distill run'
169
+ ```
170
+
171
+ Wait for the run with a hard timeout of 6 minutes (a real sweep with
172
+ one `claude --print` subprocess is typically 60–120 seconds, but the
173
+ first-ever run on a cold cache can stretch). Poll
174
+ `.cache/distill/logs/runs.jsonl`. A completed run appends one JSON line
175
+ with `"status": "ok"` or `"status": "noop-empty-staging"`. Either
176
+ status is a successful proof: the pipeline ran end to end.
177
+
178
+ Mark this step `done` only when the ledger shows a completed run. If
179
+ the install fails, or the run times out, or the ledger records a
180
+ `fail-*` status, this step is NOT `done`, onboarding does NOT complete,
181
+ and the operator gets:
182
+
183
+ 1. The exact error message (from the scheduler CLI's stderr, or the
184
+ ledger's `reason` field, or a timeout notice).
185
+ 2. The one command to retry, verbatim:
186
+ `pnpm exec scheduler install schedules/<paName>-distillation-sweep.json --force-host`
187
+
188
+ Then stop. Do not silently skip to `atlassianOffer`. The whole
189
+ onboarding is blocked on this step.
190
+
191
+ ### 7. atlassianOffer
118
192
 
119
193
  Ask if they use Jira or Confluence. On no, mark skipped. On yes, walk them
120
194
  through:
@@ -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);
@@ -0,0 +1,322 @@
1
+ // distill — librarian schedule + last-run health surface.
2
+ //
3
+ // Reads:
4
+ // 1. the operator's `schedules/<paName>-distillation-sweep.json` (does the
5
+ // schedule file exist at all — a scaffolded solo instance ships one but
6
+ // an older upgrade may not).
7
+ // 2. the distill ledger at `<repoRoot>/.cache/distill/logs/runs.jsonl`
8
+ // (last run: ts, status, durationSec, stagingCount).
9
+ // 3. the current staged-observation count via `countStagedObservations`
10
+ // (how much material is queued for the next sweep).
11
+ //
12
+ // Consumers:
13
+ // * `pa status` prints a distillation line built from `renderHealthLine`.
14
+ // * A scaffolded `SessionStart` hook injects `buildHealthHookEnvelope`'s
15
+ // output at the top of every session, so the operator sees the same
16
+ // summary without asking.
17
+ // * `distill health --json` prints the raw computed object so scripts
18
+ // can key on it.
19
+ //
20
+ // This module is READ-ONLY. It never invokes claude, never triggers a
21
+ // sweep, never touches the schedule backend. Cheap to call from a hook.
22
+
23
+ import { readFile, stat } from 'node:fs/promises';
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ import { readPreferencesSync } from '../preferences.mjs';
28
+ import { countStagedObservations } from './staging.mjs';
29
+
30
+ /**
31
+ * Default sweep cadence in hours. The scaffolded schedule fires every
32
+ * four hours; the health surface warns when the last run is older than
33
+ * `staleMultiplier * cadenceHours` (default 2 * 4 = 8 hours), so a
34
+ * one-cycle miss is quiet but a two-cycle miss surfaces.
35
+ */
36
+ export const DEFAULT_CADENCE_HOURS = 4;
37
+
38
+ /**
39
+ * How many multiples of the cadence a last-run can lag before the health
40
+ * surface labels it stale. Two is deliberate: a single missed tick is
41
+ * common (host asleep, laptop closed at the wrong minute) and would
42
+ * generate false alarms.
43
+ */
44
+ export const DEFAULT_STALE_MULTIPLIER = 2;
45
+
46
+ /**
47
+ * Read the last non-empty line of the distill ledger. Returns null when
48
+ * the ledger does not exist or has never seen a real run. All other
49
+ * read failures surface as a `{ error }` object so the caller can render
50
+ * something honest instead of crashing.
51
+ *
52
+ * The ledger is append-only JSONL and typically small (dozens of lines
53
+ * over months). Reading the whole file is cheaper than streaming the
54
+ * tail in every context we call this from.
55
+ */
56
+ export async function readLastLedgerEntry(repoRoot) {
57
+ const path = join(repoRoot, '.cache', 'distill', 'logs', 'runs.jsonl');
58
+ let raw;
59
+ try {
60
+ raw = await readFile(path, 'utf8');
61
+ } catch (err) {
62
+ if (err && err.code === 'ENOENT') return null;
63
+ return { error: `ledger read failed: ${err.message || err}` };
64
+ }
65
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
66
+ if (lines.length === 0) return null;
67
+ const last = lines[lines.length - 1];
68
+ try {
69
+ return JSON.parse(last);
70
+ } catch (err) {
71
+ return { error: `ledger last line unparseable: ${err.message || err}` };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve the schedule file name a fresh scaffold writes. Kept in one
77
+ * place so the scaffold, the health surface, and the `/onboard`
78
+ * walkthrough all agree.
79
+ */
80
+ export function distillationScheduleId(paName) {
81
+ return `${paName}-distillation-sweep`;
82
+ }
83
+
84
+ export function distillationScheduleFilename(paName) {
85
+ return `${distillationScheduleId(paName)}.json`;
86
+ }
87
+
88
+ /**
89
+ * Locate the scaffolded distillation schedule file for a given repo.
90
+ * Returns `{ path, present }`, and when present also `{ enabled, backend,
91
+ * schedule, host }` parsed from the JSON (best-effort — a corrupt file
92
+ * lands as `{ present: true, parseError: '...' }`).
93
+ */
94
+ export function readScheduleFile(repoRoot, paName) {
95
+ const path = join(repoRoot, 'schedules', distillationScheduleFilename(paName));
96
+ if (!existsSync(path)) return { path, present: false };
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
99
+ return {
100
+ path,
101
+ present: true,
102
+ enabled: parsed.enabled !== false,
103
+ backend: parsed.backend ?? null,
104
+ schedule: parsed.schedule ?? null,
105
+ host: parsed.host ?? null,
106
+ id: parsed.id ?? null,
107
+ };
108
+ } catch (err) {
109
+ return { path, present: true, parseError: err.message || String(err) };
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Resolve the PA name from `operator/config/preferences.json`. Falls
115
+ * back to null (the caller reports missing-preferences instead of
116
+ * guessing a name).
117
+ */
118
+ export function resolvePaName(repoRoot) {
119
+ try {
120
+ const prefs = readPreferencesSync(repoRoot);
121
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0) {
122
+ return prefs.paName;
123
+ }
124
+ } catch { /* fall through */ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Compose the full distillation-health object for a given operator repo.
130
+ *
131
+ * Shape:
132
+ * {
133
+ * repoRoot, paName, cadenceHours,
134
+ * schedule: { path, present, enabled?, backend?, schedule?, host?, id? } | { present: false } | { parseError },
135
+ * lastRun: null | { ts, status, exitCode, durationSec, stagingCount, log, reason?, ageSeconds? } | { error },
136
+ * stagingCount: number | { error },
137
+ * warnings: string[],
138
+ * ok: boolean,
139
+ * generatedAt: string (ISO)
140
+ * }
141
+ *
142
+ * `ok` is true when the schedule file is present and enabled, staging
143
+ * is readable, and (if there is any last run at all) that run's status
144
+ * is not a `fail-*` and its age is inside `staleMultiplier * cadenceHours`.
145
+ * A brand-new instance with no runs yet is `ok: true` with a warning; the
146
+ * warning describes the exact next action ("run `distill run` to seed
147
+ * the ledger").
148
+ */
149
+ export async function computeDistillationHealth({
150
+ repoRoot,
151
+ paName = null,
152
+ cadenceHours = DEFAULT_CADENCE_HOURS,
153
+ staleMultiplier = DEFAULT_STALE_MULTIPLIER,
154
+ now = new Date(),
155
+ } = {}) {
156
+ if (!repoRoot) throw new Error('computeDistillationHealth: repoRoot is required');
157
+ const resolvedName = paName ?? resolvePaName(repoRoot);
158
+ const warnings = [];
159
+ let ok = true;
160
+
161
+ // Schedule file — presence is a hard requirement (without it, no
162
+ // scheduled sweep runs, ever). Missing is a warning, not a throw:
163
+ // the surface still needs to render for pa status.
164
+ let schedule;
165
+ if (!resolvedName) {
166
+ schedule = { present: false, path: null };
167
+ warnings.push('no paName in operator/config/preferences.json — cannot resolve the distillation schedule file');
168
+ ok = false;
169
+ } else {
170
+ schedule = readScheduleFile(repoRoot, resolvedName);
171
+ if (!schedule.present) {
172
+ warnings.push(
173
+ `no distillation schedule at schedules/${distillationScheduleFilename(resolvedName)} — run /onboard to install one`,
174
+ );
175
+ ok = false;
176
+ } else if (schedule.parseError) {
177
+ warnings.push(`distillation schedule file is corrupt: ${schedule.parseError}`);
178
+ ok = false;
179
+ } else if (schedule.enabled === false) {
180
+ warnings.push('distillation schedule is disabled (schedule.enabled === false)');
181
+ ok = false;
182
+ }
183
+ }
184
+
185
+ // Last-run ledger — null (never run), an error, or the last record.
186
+ const lastRun = await readLastLedgerEntry(repoRoot);
187
+ let annotatedLastRun = lastRun;
188
+ const cadenceSeconds = cadenceHours * 3600;
189
+ if (lastRun && !lastRun.error && lastRun.ts) {
190
+ const ageSeconds = Math.max(0, Math.round((now.getTime() - new Date(lastRun.ts).getTime()) / 1000));
191
+ annotatedLastRun = { ...lastRun, ageSeconds };
192
+ if (typeof lastRun.status === 'string' && lastRun.status.startsWith('fail-')) {
193
+ warnings.push(`last distillation run failed: ${lastRun.status}${lastRun.reason ? ` (${lastRun.reason})` : ''}`);
194
+ ok = false;
195
+ }
196
+ if (ageSeconds > staleMultiplier * cadenceSeconds) {
197
+ warnings.push(
198
+ `last distillation run was ${formatDuration(ageSeconds)} ago (cadence ${cadenceHours}h) — the scheduled sweep may not be firing`,
199
+ );
200
+ ok = false;
201
+ }
202
+ } else if (!lastRun) {
203
+ warnings.push('no distillation runs recorded yet — the ledger will populate on the first sweep');
204
+ } else if (lastRun && lastRun.error) {
205
+ warnings.push(lastRun.error);
206
+ ok = false;
207
+ }
208
+
209
+ // Staging count — how much material is queued.
210
+ let stagingCount;
211
+ try {
212
+ stagingCount = await countStagedObservations(repoRoot);
213
+ } catch (err) {
214
+ stagingCount = { error: `staging read failed: ${err.message || err}` };
215
+ warnings.push(stagingCount.error);
216
+ }
217
+
218
+ return {
219
+ repoRoot,
220
+ paName: resolvedName,
221
+ cadenceHours,
222
+ schedule,
223
+ lastRun: annotatedLastRun,
224
+ stagingCount,
225
+ warnings,
226
+ ok,
227
+ generatedAt: now.toISOString(),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Format a duration in seconds as a compact human-readable label. Chosen
233
+ * so the surface stays scan-friendly at both very-recent (`5s ago`) and
234
+ * very-stale (`3d 4h ago`) extremes without a dependency.
235
+ */
236
+ export function formatDuration(seconds) {
237
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return 'unknown';
238
+ if (seconds < 60) return `${seconds}s`;
239
+ const minutes = Math.floor(seconds / 60);
240
+ if (minutes < 60) return `${minutes}m`;
241
+ const hours = Math.floor(minutes / 60);
242
+ const rMin = minutes % 60;
243
+ if (hours < 24) return rMin ? `${hours}h ${rMin}m` : `${hours}h`;
244
+ const days = Math.floor(hours / 24);
245
+ const rHr = hours % 24;
246
+ return rHr ? `${days}d ${rHr}h` : `${days}d`;
247
+ }
248
+
249
+ /**
250
+ * Render a single-line summary suitable for the `pa status` output or a
251
+ * one-line boot briefing. Always fits on a terminal line, and never
252
+ * omits the warning class when one is present.
253
+ */
254
+ export function renderHealthLine(health) {
255
+ const parts = [];
256
+ if (health.schedule?.present && health.schedule.enabled !== false) {
257
+ parts.push(`schedule: installed (${health.schedule.backend || 'backend?'}, ${health.schedule.schedule || '?'})`);
258
+ } else if (health.schedule?.present === false) {
259
+ parts.push('schedule: MISSING');
260
+ } else if (health.schedule?.enabled === false) {
261
+ parts.push('schedule: DISABLED');
262
+ } else if (health.schedule?.parseError) {
263
+ parts.push('schedule: CORRUPT');
264
+ } else {
265
+ parts.push('schedule: unknown');
266
+ }
267
+ if (health.lastRun && !health.lastRun.error && health.lastRun.ts) {
268
+ const age = typeof health.lastRun.ageSeconds === 'number'
269
+ ? formatDuration(health.lastRun.ageSeconds) + ' ago'
270
+ : health.lastRun.ts;
271
+ parts.push(`last run: ${health.lastRun.status || '?'} (${age})`);
272
+ } else if (health.lastRun && health.lastRun.error) {
273
+ parts.push('last run: ledger read failed');
274
+ } else {
275
+ parts.push('last run: never');
276
+ }
277
+ if (typeof health.stagingCount === 'number') {
278
+ parts.push(`staging: ${health.stagingCount}`);
279
+ } else if (health.stagingCount && health.stagingCount.error) {
280
+ parts.push('staging: unreadable');
281
+ }
282
+ const status = health.warnings.length === 0 ? 'ok' : 'warn';
283
+ return `[${status}] ${parts.join(' | ')}`;
284
+ }
285
+
286
+ /**
287
+ * Render a multi-line markdown block suitable for a SessionStart hook's
288
+ * `additionalContext` — the same shape autonomy-injection uses. Kept
289
+ * compact: a heading, the one-line summary, the warnings list, and the
290
+ * remediation command when the surface is not ok.
291
+ */
292
+ export function renderHealthBlock(health) {
293
+ const lines = [];
294
+ lines.push('# distillationHealth (live)');
295
+ lines.push('');
296
+ lines.push(renderHealthLine(health));
297
+ if (health.warnings.length > 0) {
298
+ lines.push('');
299
+ lines.push('Warnings:');
300
+ for (const w of health.warnings) lines.push(`- ${w}`);
301
+ }
302
+ if (!health.ok) {
303
+ lines.push('');
304
+ lines.push('Remediation: run `/onboard` (idempotent), then `pnpm exec distill run` to seed the ledger.');
305
+ }
306
+ lines.push('');
307
+ return lines.join('\n');
308
+ }
309
+
310
+ /**
311
+ * Build the full Claude Code SessionStart hook envelope. The hook prints
312
+ * this JSON to stdout; Claude Code injects `additionalContext` into the
313
+ * session.
314
+ */
315
+ export function buildHealthHookEnvelope(health) {
316
+ return {
317
+ hookSpecificOutput: {
318
+ hookEventName: 'SessionStart',
319
+ additionalContext: renderHealthBlock(health),
320
+ },
321
+ };
322
+ }
@@ -7,13 +7,19 @@
7
7
  // kind: 'dir' (mkdir -p) | 'file' (write file with contents) | 'json'
8
8
  // (stringify + write).
9
9
 
10
- import { existsSync, readFileSync } from 'node:fs';
10
+ import { existsSync, readFileSync, statSync } from 'node:fs';
11
11
  import { readFile, readdir } from 'node:fs/promises';
12
12
  import { createRequire } from 'node:module';
13
- import { dirname, join, resolve } from 'node:path';
13
+ import { hostname, platform } from 'node:os';
14
+ import { delimiter as pathDelimiter, dirname, join, resolve } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
16
17
  import { emptyEnvelope, renderEnvelope } from '../../engineering-pa-core/src/autonomy/envelope.mjs';
18
+ import {
19
+ DEFAULT_CADENCE_HOURS,
20
+ distillationScheduleFilename,
21
+ distillationScheduleId,
22
+ } from '../../engineering-pa-core/src/distill/health.mjs';
17
23
 
18
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
25
  const requireFromHere = createRequire(import.meta.url);
@@ -553,6 +559,13 @@ export function buildOnboardingStateJson(config) {
553
559
  profileIntake: 'pending',
554
560
  aliasOffer: 'pending',
555
561
  workspaceOffer: 'pending',
562
+ // Mandatory — no opt-out. The distillation sweep is the only thing
563
+ // that turns staged observations into knowledge docs; without an
564
+ // installed schedule the whole self-learning loop is dead. The
565
+ // /onboard walkthrough runs `scheduler install` on the scaffolded
566
+ // schedule file and triggers one real run before it marks this
567
+ // step ok. See packages/core/docs/commands/onboard.md.
568
+ librarianSchedule: 'pending',
556
569
  atlassianOffer: 'pending',
557
570
  },
558
571
  completedAt: null,
@@ -567,10 +580,180 @@ export function buildGitkeep() {
567
580
  return '';
568
581
  }
569
582
 
583
+ /**
584
+ * Pick the OS-native scheduler backend the scaffold writes into the
585
+ * distillation-sweep schedule file. macOS -> launchd, Linux -> cron,
586
+ * Windows -> schtasks. Every other platform lands on `null`, which
587
+ * makes `scheduler install` fall back to detect + refusal (with a
588
+ * clean stderr message).
589
+ *
590
+ * Kept as a switch on `process.platform` rather than a call to
591
+ * scheduler/detect.mjs: the schedule file is written once at scaffold
592
+ * time and shipped in the repo, so the backend choice must be stable
593
+ * (a `detect()` reading brew-installed cron vs launchd is a per-host
594
+ * moment-in-time decision that should not be baked into a tracked
595
+ * file). Every host later runs `scheduler install --force-host` from
596
+ * its own real backend; the schedule file's `backend` field just names
597
+ * the default. Exported for tests.
598
+ *
599
+ * @param {string} [plat] - override for tests; defaults to `process.platform`.
600
+ * @returns {'launchd'|'cron'|'schtasks'|null}
601
+ */
602
+ export function defaultSchedulerBackend(plat = platform()) {
603
+ if (plat === 'darwin') return 'launchd';
604
+ if (plat === 'linux') return 'cron';
605
+ if (plat === 'win32') return 'schtasks';
606
+ return null;
607
+ }
608
+
609
+ // System paths the launchd defaults already carry (see
610
+ // packages/core/src/scheduler/install.mjs withLaunchdDefaults). If
611
+ // `claude` resolves inside any of these, no explicit PATH is needed on
612
+ // the scaffolded schedule — the installer's baked-in PATH covers it.
613
+ const LAUNCHD_DEFAULT_PATHS = [
614
+ '/opt/homebrew/bin',
615
+ '/usr/local/bin',
616
+ '/usr/bin',
617
+ '/bin',
618
+ '/usr/sbin',
619
+ '/sbin',
620
+ ];
621
+
622
+ /**
623
+ * Walk `process.env.PATH` at scaffold time looking for the `claude`
624
+ * executable. Returns the resolved directory, or null if nothing on
625
+ * PATH matches. The scaffold uses this to decide whether to bake an
626
+ * explicit `environment.PATH` into the schedule file (needed when the
627
+ * operator's claude install lives somewhere the launchd default PATH
628
+ * would not find — e.g. ~/.local/bin from a curl-installer). Exported
629
+ * for tests, which drive it with a fake PATH.
630
+ *
631
+ * @param {object} [opts]
632
+ * @param {string} [opts.pathEnv] - override for tests; defaults to process.env.PATH.
633
+ * @param {string} [opts.binName] - override for tests; defaults to `claude`.
634
+ * @returns {string|null}
635
+ */
636
+ export function resolveClaudeBinDir({ pathEnv, binName = 'claude' } = {}) {
637
+ const raw = pathEnv ?? process.env.PATH ?? '';
638
+ const dirs = raw.split(pathDelimiter).filter(Boolean);
639
+ for (const dir of dirs) {
640
+ const candidate = join(dir, binName);
641
+ let st;
642
+ try {
643
+ st = statSync(candidate);
644
+ } catch {
645
+ continue;
646
+ }
647
+ if (!st.isFile()) continue;
648
+ if ((st.mode & 0o111) === 0) continue;
649
+ return dir;
650
+ }
651
+ return null;
652
+ }
653
+
654
+ /**
655
+ * Build the operator repo's scaffolded `schedules/<paName>-distillation-sweep.json`.
656
+ *
657
+ * This is the single load-bearing artefact that makes the whole
658
+ * self-learning loop work in a solo instance. Without a scheduled
659
+ * sweep, `agents/<paName>/staging/` fills up forever and no knowledge
660
+ * doc is ever distilled. The `/onboard` walkthrough runs
661
+ * `scheduler install` on this file (with --force-host to survive the
662
+ * macOS hostname flap) as a mandatory step before onboarding is marked
663
+ * complete.
664
+ *
665
+ * Shape choices, with the estate lore they close:
666
+ * - `command: node node_modules/@stravica-ai/digital-operator-pa/bin/distill run`.
667
+ * Absolute node path is resolved by the scheduler at install time
668
+ * (`resolveLaunchdArgv0('node', ...)` returns `process.execPath`);
669
+ * the bin path goes through the stable `node_modules/@stravica-ai/…`
670
+ * symlink rather than the versioned `.pnpm` store path, so a
671
+ * package upgrade does not silently break the schedule (Dex lore
672
+ * w-2026-07-16-006).
673
+ * - `workingDirectory` is the absolute `targetDir`. No `${DAVE_ROOT}`
674
+ * token — a solo instance is not the estate and does not have that
675
+ * env var; a fresh `os.hostname()`-bound scheduler install with an
676
+ * unset DAVE_ROOT would otherwise fall through to a bogus
677
+ * `node_modules`-rooted path (`validateWorkDir` refuses that at
678
+ * install time — good, but the scaffold should not create that
679
+ * failure mode in the first place).
680
+ * - `host` is the current machine's hostname as `os.hostname()`
681
+ * computes it, so `scheduler install` without `--force-host` picks
682
+ * up the file cleanly on the scaffolded machine, and refuses on any
683
+ * other. The /onboard step passes `--force-host` unconditionally
684
+ * because macOS hostnames flap between the DHCP name and the
685
+ * configured name (w018 hostname-flap precedent), and a well-formed
686
+ * refusal at first-run onboarding is the worst possible operator
687
+ * experience.
688
+ * - `enabled: true`. This is not opt-in.
689
+ * - `schedule: '0 * / 4 * * *'` — every 4 hours, on the hour. Matches
690
+ * the estate's original distillation-sweep cadence and cheap enough
691
+ * that a solo instance never notices the tick.
692
+ * - `environment.PATH` is included ONLY when `claude` resolves at
693
+ * scaffold time OUTSIDE the launchd default PATH set (e.g. ~/.local/bin
694
+ * from the curl installer). In the common brew case it's covered by
695
+ * `withLaunchdDefaults()` and setting an explicit PATH would
696
+ * override — not merge — that default and lose the standard
697
+ * directories (w-2026-08-05 EnvironmentVariables trap).
698
+ *
699
+ * @param {object} config
700
+ * @param {string} config.paName
701
+ * @param {string} config.targetDir - absolute
702
+ * @param {object} [seams]
703
+ * @param {string} [seams.host] - hostname override (for tests)
704
+ * @param {'launchd'|'cron'|'schtasks'|null} [seams.backend] - override (for tests)
705
+ * @param {string|null} [seams.claudeBinDir] - override (for tests)
706
+ * @returns {object}
707
+ */
708
+ export function buildDistillationScheduleJson(config, seams = {}) {
709
+ const backend = seams.backend !== undefined ? seams.backend : defaultSchedulerBackend();
710
+ const host = seams.host !== undefined ? seams.host : hostname();
711
+ const claudeBinDir = seams.claudeBinDir !== undefined
712
+ ? seams.claudeBinDir
713
+ : resolveClaudeBinDir();
714
+
715
+ const out = {
716
+ id: distillationScheduleId(config.paName),
717
+ agent: config.paName,
718
+ name: `Librarian distillation sweep (every ${DEFAULT_CADENCE_HOURS}h)`,
719
+ schedule: `0 */${DEFAULT_CADENCE_HOURS} * * *`,
720
+ command: 'node node_modules/@stravica-ai/digital-operator-pa/bin/distill run',
721
+ workingDirectory: config.targetDir,
722
+ enabled: true,
723
+ backend,
724
+ host,
725
+ description: [
726
+ `Solo-instance librarian distillation sweep for ${config.paName}. Turns the`,
727
+ 'staged observations under agents/<paName>/staging/ into promoted knowledge',
728
+ 'docs under operator/knowledge/, then invalidates the retrieval graph so the',
729
+ 'next `pa knowledge search` sees the new material. Installed by the /onboard',
730
+ 'walkthrough (mandatory step) via `scheduler install --force-host`. Removing',
731
+ 'this schedule silently kills the PA\'s self-learning loop; reinstate it by',
732
+ 're-running /onboard.',
733
+ ].join(' '),
734
+ // DAVE_ROOT MUST be set explicitly — the scheduler's withLaunchdDefaults()
735
+ // falls back to `resolve(__dirname, '..','..','..')` which under a
736
+ // published tarball lands inside the versioned .pnpm store path and
737
+ // `validateLaunchdDaveRoot` refuses (exit 2) at install time. The
738
+ // solo colleague running `scheduler install` from their instance
739
+ // shell also does not typically have DAVE_ROOT set. Baking it into
740
+ // the schedule closes both cases (w-2026-07-15-003 + w-2026-07-16-006
741
+ // trap class). PATH is only added when `claude` lives outside the
742
+ // launchd default PATH set.
743
+ environment: { DAVE_ROOT: config.targetDir },
744
+ };
745
+
746
+ if (claudeBinDir && !LAUNCHD_DEFAULT_PATHS.includes(claudeBinDir)) {
747
+ out.environment.PATH = [claudeBinDir, ...LAUNCHD_DEFAULT_PATHS].join(pathDelimiter);
748
+ }
749
+
750
+ return out;
751
+ }
752
+
570
753
  /**
571
754
  * Build the .claude/settings.json contents.
572
755
  *
573
- * Two things:
756
+ * Three things:
574
757
  * 1. `permissions.deny` for the built-in `deep-research` skill and
575
758
  * workflow. The core operating model (Bucket 4) directs the PA to
576
759
  * the `/research` verb, which persists a session and promotes
@@ -584,12 +767,16 @@ export function buildGitkeep() {
584
767
  * scaffold ships the wiring inert. A per-repo overlay slot is
585
768
  * scaffolded alongside as `.claude/coordinator-overlay.json`.
586
769
  *
587
- * SessionStart hooks (boot-context style injection) are deliberately
588
- * NOT scaffolded — the boot-context tool lives in the HQ estate, not
589
- * the published package. Scaffolding a hook that names a binary the
590
- * colleague does not have would fire on every session boot with a
591
- * non-blocking error. Documented in docs/onboarding-solo.md under
592
- * "what is not included in solo mode".
770
+ * 3. `hooks.SessionStart` wiring `distill health --hook-envelope` so
771
+ * every session boots with a `distillationHealth (live)` block at
772
+ * the top of the context. Reads the local distill ledger + the
773
+ * scaffolded schedule file — never spawns claude, never triggers a
774
+ * sweep, cheap to run. The block warns loudly when the schedule is
775
+ * missing (fresh scaffold pre-/onboard), disabled, or the last run
776
+ * is stale, so the whole self-learning loop cannot die silently
777
+ * the way it did on the WSD-laptop cold install of 0.1.41. Invoked
778
+ * through `pnpm exec` because Claude Code hook subprocesses do not
779
+ * inherit the pnpm-augmented PATH the interactive session enjoys.
593
780
  */
594
781
  export function buildClaudeSettingsJson() {
595
782
  return {
@@ -608,6 +795,17 @@ export function buildClaudeSettingsJson() {
608
795
  ],
609
796
  },
610
797
  ],
798
+ SessionStart: [
799
+ {
800
+ matcher: '',
801
+ hooks: [
802
+ {
803
+ type: 'command',
804
+ command: 'pnpm exec distill health --hook-envelope',
805
+ },
806
+ ],
807
+ },
808
+ ],
611
809
  },
612
810
  };
613
811
  }
@@ -810,12 +1008,23 @@ export function buildPlanFromTemplates(config, templates) {
810
1008
  plan.push({ kind: 'file', path: `agents/${paName}/autonomy-envelope.md`, contents: autonomyEnvelope });
811
1009
  plan.push({ kind: 'json', path: `agents/${paName}/spawn-log.json`, contents: spawnLog });
812
1010
 
813
- // Empty-directory markers — git won't track empty dirs.
1011
+ // Load-bearing: the scaffolded distillation-sweep schedule the
1012
+ // /onboard walkthrough installs and fires as a mandatory step. See
1013
+ // buildDistillationScheduleJson for the shape rationale.
1014
+ const distillSched = buildDistillationScheduleJson(config);
1015
+ plan.push({
1016
+ kind: 'json',
1017
+ path: `schedules/${distillationScheduleFilename(paName)}`,
1018
+ contents: distillSched,
1019
+ });
1020
+
1021
+ // Empty-directory markers — git won't track empty dirs. `schedules/`
1022
+ // now ships with a real file (the distillation sweep) so it never
1023
+ // needs a .gitkeep.
814
1024
  for (const d of [
815
1025
  `agents/${paName}/staging`,
816
1026
  'work',
817
1027
  'projects',
818
- 'schedules',
819
1028
  '.vault',
820
1029
  'operator/extensions/roles',
821
1030
  'operator/extensions/tools',
@@ -37,7 +37,7 @@ cd $HOME/<pa-name>-instance
37
37
  pnpm exec pa session
38
38
  ```
39
39
 
40
- Inside the session, type `/onboard`. The PA verifies the runtime, checks the installed version against npm, asks you a few profile questions, offers an optional shell alias, offers to start the local workspace, and offers to wire up Atlassian (Jira, Confluence) if you use it. Every step is idempotent; re-run `/onboard` anytime to redo a specific step.
40
+ Inside the session, type `/onboard`. The PA verifies the runtime, checks the installed version against npm, asks you a few profile questions, offers an optional shell alias, offers to start the local workspace, installs and proves the librarian distillation sweep as a mandatory step, and offers to wire up Atlassian (Jira, Confluence) if you use it. Every step is idempotent; re-run `/onboard` anytime to redo a specific step. Onboarding does not complete until the librarian schedule is installed and proven. That is the one piece of scheduling every solo instance needs so staged observations get distilled into knowledge over time.
41
41
 
42
42
  ## 3. Alias (optional)
43
43
 
@@ -56,11 +56,11 @@ After that, typing `<pa-name>` from anywhere starts a session.
56
56
  - The read-only `desk` CLI: overview, today, projects, work.
57
57
  - A local workspace server on `http://localhost:4200` (start with `pnpm exec pa workspace start`).
58
58
  - A SIMPLE-mode local knowledge library: `pa knowledge search / related / domain / orphans / path / context`, seeded with the five base domains.
59
- - Self-learning loop primitives: `agents/<pa-name>/staging/`, `journal.md`, `next-session.md`, `spawn-log.json`, autonomy envelope, and the Librarian distillation sweep via `pnpm exec distill run`.
59
+ - Self-learning loop primitives: `agents/<pa-name>/staging/`, `journal.md`, `next-session.md`, `spawn-log.json`, autonomy envelope, and the Librarian distillation sweep, scaffolded as `schedules/<pa-name>-distillation-sweep.json` (every 4 hours) and installed by `/onboard` on the OS-native backend (launchd on macOS, cron on Linux). One immediate run is fired at install time so the ledger at `.cache/distill/logs/runs.jsonl` proves the schedule wiring end to end before onboarding completes.
60
60
  - The `research` CLI with the light / normal / deep session store persisted under `.cache/research/`.
61
61
  - Scheduler (cron on Linux, launchd on macOS): `pnpm exec scheduler --help`.
62
62
  - Secrets vault (SOPS + age) if you opt in: `pnpm exec secrets --help`.
63
- - `.claude/settings.json` with the `pa-coordinator-hook` wired in as a `PreToolUse` hook (inert until you export `PA_COORDINATOR_MODE=1`) and `deep-research` denied at the permission layer.
63
+ - `.claude/settings.json` with the `pa-coordinator-hook` wired in as a `PreToolUse` hook (inert until you export `PA_COORDINATOR_MODE=1`), a `SessionStart` hook that injects a live `distillationHealth` block at the top of every session (schedule state + last-run age + staging count), and `deep-research` denied at the permission layer.
64
64
  - Two slash commands out of the box: `/research` (persisted research sessions) and `/onboard` (this walkthrough).
65
65
  - Workspace-extension prompting: `operator/knowledge/platform-tooling/workspace-extensions.md` teaches your PA when to propose a workspace page and `pa-init workspace-extension <name>` scaffolds the HTML skeleton plus a `nav.json` entry. Add `--with-api` when the page needs a server route.
66
66
 
@@ -70,7 +70,7 @@ Your PA has the `rcf-lite-engineer` role baked in. It builds greenfield applicat
70
70
 
71
71
  ## What is not included in solo mode
72
72
 
73
- - **`SessionStart` boot-context hook.** In some estates a `SessionStart` hook injects a live workState block at the top of every session. That hook is not in this package. If you want it, write your own `SessionStart` hook in `.claude/settings.json`.
73
+ - **A full workState boot-context hook.** In some estates a `SessionStart` hook injects the live in-flight work items, active projects, and on-you list at the top of every session. Solo mode ships the narrower distillation-health hook (schedule + last-run + staging count) rather than a full workState block. If you want the broader one, extend `.claude/settings.json` with your own `SessionStart` hook.
74
74
  - **Team modes.** `pa-init --mode=lead` and `--mode=member` exit with "not implemented". The SIMPLE-mode local library is single-operator by design.
75
75
  - **Remote knowledge library.** The `remote` library mode requires a service on a persistent host, an age keypair, and a tunnel. Solo mode stays SIMPLE and runs entirely on your one laptop.
76
76
  - **HQ-specialised roles.** The bundled role set is the core seven plus `product-engineer` and `rcf-lite-engineer` from the engineering types pack. Other tier-ladder roles and estate-specific extensions are not shipped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",