@stravica-ai/digital-operator-pa 0.1.47 → 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.
Files changed (35) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +24 -1
  2. package/dist/engineering-pa-core/docs/commands/onboard.md +81 -7
  3. package/dist/engineering-pa-core/src/distill/cli.mjs +65 -0
  4. package/dist/engineering-pa-core/src/distill/health.mjs +322 -0
  5. package/dist/engineering-pa-scaffold/package.json +3 -2
  6. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +37 -5
  7. package/dist/engineering-pa-scaffold/src/commands/init.mjs +7 -1
  8. package/dist/engineering-pa-scaffold/src/generator.mjs +220 -11
  9. package/dist/engineering-pa-workspace/app/assets/theme.css +3 -2
  10. package/dist/engineering-pa-workspace/app/assets/theme.js +29 -6
  11. package/dist/engineering-pa-workspace/app/banner.js +14 -2
  12. package/dist/engineering-pa-workspace/app/delegations.html +1 -1
  13. package/dist/engineering-pa-workspace/app/home.html +1 -1
  14. package/dist/engineering-pa-workspace/app/index.html +1 -1
  15. package/dist/engineering-pa-workspace/app/knowledge-map.html +1 -1
  16. package/dist/engineering-pa-workspace/app/knowledge.html +1 -1
  17. package/dist/engineering-pa-workspace/app/notes.html +1 -1
  18. package/dist/engineering-pa-workspace/app/onboarding.html +1 -1
  19. package/dist/engineering-pa-workspace/app/profile.html +1 -1
  20. package/dist/engineering-pa-workspace/app/project-detail.html +1 -1
  21. package/dist/engineering-pa-workspace/app/projects.html +1 -1
  22. package/dist/engineering-pa-workspace/app/repos.html +1 -1
  23. package/dist/engineering-pa-workspace/app/schedules.html +1 -1
  24. package/dist/engineering-pa-workspace/app/secrets.html +1 -1
  25. package/dist/engineering-pa-workspace/app/staging.html +1 -1
  26. package/dist/engineering-pa-workspace/app/tasks.html +1 -1
  27. package/dist/engineering-pa-workspace/app/viewer.html +1 -1
  28. package/dist/engineering-pa-workspace/index.mjs +16 -0
  29. package/dist/engineering-pa-workspace/package.json +3 -2
  30. package/dist/engineering-pa-workspace/src/repo-root.mjs +26 -1
  31. package/dist/engineering-pa-workspace/src/server.mjs +4 -3
  32. package/dist/engineering-pa-workspace/src/workspace-page-contract.mjs +98 -0
  33. package/dist/engineering-pa-workspace/src/ws.mjs +2 -0
  34. package/docs/onboarding-solo.md +4 -4
  35. package/package.json +2 -1
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-scaffold",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "pa-init scaffolder + onboarding flows (solo, lead, member).",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -11,7 +11,8 @@
11
11
  "pa-init": "bin/pa-init"
12
12
  },
13
13
  "dependencies": {
14
- "@stravica-ai/engineering-pa-core": "workspace:*"
14
+ "@stravica-ai/engineering-pa-core": "workspace:*",
15
+ "@stravica-ai/engineering-pa-workspace": "workspace:*"
15
16
  },
16
17
  "private": true,
17
18
  "license": "MIT",
@@ -21,6 +21,18 @@ import { existsSync } from 'node:fs';
21
21
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
22
22
  import { dirname, isAbsolute, join, resolve } from 'node:path';
23
23
 
24
+ // Direct file import (not the bare package specifier) so the build.mjs
25
+ // import-rewriter produces a full-filename relative path — Node ESM won't
26
+ // resolve a directory-only path without a package.json main entry, and
27
+ // the bundled dist tree does not carry one for internal packages.
28
+ import {
29
+ BANNER_JS_TAG,
30
+ GLOBAL_BANNER_HTML,
31
+ THEME_CSS_LINK,
32
+ THEME_JS_TAG,
33
+ prepaintScriptTag,
34
+ } from '../../../engineering-pa-workspace/index.mjs';
35
+
24
36
  /** Kebab-case validator: 2-40 chars, lowercase letters + digits + hyphen, starts with a letter. */
25
37
  const NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
26
38
 
@@ -163,21 +175,41 @@ export async function mergeNavEntry({ navPath, name, label, section }) {
163
175
 
164
176
  /**
165
177
  * The static-page skeleton written for `<name>.html`. Kept minimal on
166
- * purpose: the operator's PA fills in the real content on the next
167
- * dispatch, and a heavier template would just be deleted.
178
+ * content but complete on chrome: an extension page carries the same
179
+ * head + top-of-body contract every core page ships, so a freshly
180
+ * scaffolded page renders in the operator's chosen theme and shows the
181
+ * global status banner from the first load.
182
+ *
183
+ * The head tags come from
184
+ * `@stravica-ai/engineering-pa-workspace/src/workspace-page-contract.mjs`
185
+ * so a scaffolded page and a core page can never drift on the theme
186
+ * storage key or the tag order. Previously this used a single
187
+ * `<link rel="stylesheet" href="/core/tools/workspace/app/shell.css" />`
188
+ * — that path never shipped, so every scaffolded page rendered
189
+ * unstyled and outside the theme system.
190
+ *
191
+ * Core pages still hand-copy the contract for now (their inline `<style>`
192
+ * blocks sit between the theme tags and `banner.js`, so a full migration
193
+ * is more than a mechanical substitution and is tracked as a follow-up).
194
+ * A parity test pins the tokens so a rename in either place fails fast.
168
195
  */
169
196
  export function buildHtmlSkeleton({ name, label }) {
197
+ const title = escapeHtml(label);
170
198
  return `<!doctype html>
171
199
  <html lang="en">
172
200
  <head>
173
201
  <meta charset="utf-8" />
174
202
  <meta name="viewport" content="width=device-width, initial-scale=1" />
175
- <title>${escapeHtml(label)}</title>
176
- <link rel="stylesheet" href="/core/tools/workspace/app/shell.css" />
203
+ <title>${title}</title>
204
+ ${prepaintScriptTag()}
205
+ ${THEME_CSS_LINK}
206
+ ${THEME_JS_TAG}
207
+ ${BANNER_JS_TAG}
177
208
  </head>
178
209
  <body>
210
+ ${GLOBAL_BANNER_HTML}
179
211
  <main class="page">
180
- <h1>${escapeHtml(label)}</h1>
212
+ <h1>${title}</h1>
181
213
  <!-- workspace-extension:${name} -->
182
214
  <p>Replace this with the real page. Data lives under <code>/__api/${name}/...</code> if you scaffolded the api stub.</p>
183
215
  </main>
@@ -112,6 +112,12 @@ export async function runInit(options) {
112
112
  */
113
113
  function writeNextStepMessage(config) {
114
114
  const { paName, targetDir } = config;
115
+ // Prose renders the PA name Title-Cased so a sentence starting with it
116
+ // reads naturally (e.g. "Arry walks you through..." rather than
117
+ // "arry walks you through..."). The shell-alias lines still use the
118
+ // lowercase kebab-case identifier because that's what gets typed at a
119
+ // shell prompt.
120
+ const paTitle = paName.length ? paName[0].toUpperCase() + paName.slice(1) : paName;
115
121
  const lines = [
116
122
  '',
117
123
  'Next step, start your PA:',
@@ -119,7 +125,7 @@ function writeNextStepMessage(config) {
119
125
  ` cd ${targetDir}`,
120
126
  ' pnpm exec pa session',
121
127
  '',
122
- `Inside the session, type \`/onboard\`. ${paName} walks you through the`,
128
+ `Inside the session, type \`/onboard\`. ${paTitle} walks you through the`,
123
129
  'remaining verification and optional setup (runtime check, version check,',
124
130
  'profile intake, alias, workspace, Atlassian). Every step is idempotent.',
125
131
  '',