@stravica-ai/digital-operator-pa 0.1.49 → 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 (30) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +47 -1
  2. package/dist/engineering-pa-cli/src/pa/onboard.mjs +114 -0
  3. package/dist/engineering-pa-core/docs/commands/onboard.md +20 -20
  4. package/dist/engineering-pa-core/src/distill/health.mjs +170 -6
  5. package/dist/engineering-pa-core/src/distill/run.mjs +19 -0
  6. package/dist/engineering-pa-core/src/instance-root/find.mjs +104 -0
  7. package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +6 -1
  8. package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +6 -1
  9. package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +4 -1
  10. package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +6 -1
  11. package/dist/engineering-pa-core/src/scheduler/install.mjs +41 -4
  12. package/dist/engineering-pa-core/src/scheduler/list.mjs +34 -13
  13. package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -1
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +3 -0
  15. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +4 -2
  16. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +4 -2
  17. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +6 -2
  18. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +7 -3
  19. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +9 -3
  20. package/dist/engineering-pa-scaffold/src/generator.mjs +3 -1
  21. package/dist/engineering-pa-workspace/app/notes.html +1 -1
  22. package/dist/engineering-pa-workspace/app/project-detail.html +17 -1
  23. package/dist/engineering-pa-workspace/app/projects.html +1 -1
  24. package/dist/engineering-pa-workspace/app/tasks.html +1 -1
  25. package/dist/engineering-pa-workspace/src/install-daemon.mjs +2 -2
  26. package/dist/engineering-pa-workspace/src/repo-root.mjs +13 -100
  27. package/dist/engineering-pa-workspace/src/server.mjs +2 -2
  28. package/dist/engineering-pa-workspace/src/ws.mjs +79 -10
  29. package/docs/onboarding-solo.md +1 -1
  30. package/package.json +1 -1
@@ -16,6 +16,7 @@ import {
16
16
  renderHealthLine,
17
17
  } from '../../../engineering-pa-core/src/distill/health.mjs';
18
18
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
19
+ import { doOnboard } from './onboard.mjs';
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
22
 
@@ -38,6 +39,7 @@ Usage:
38
39
  pa migrate-inspect [options] Host inspection for migration planning
39
40
  pa launch --agent=<n> --task=<id> [--dry-run]
40
41
  Boot a scoped agent instance bound to an identity and task
42
+ pa onboard <verb> Onboarding walkthrough state (complete)
41
43
  pa -h, --help Show this help
42
44
 
43
45
  Routed sub-CLIs (also available as standalone bins):
@@ -92,6 +94,8 @@ export async function main(argv) {
92
94
  return await runMigrateInspect(rest);
93
95
  case 'launch':
94
96
  return await doLaunch(rest);
97
+ case 'onboard':
98
+ return await doOnboard(rest);
95
99
  default:
96
100
  process.stderr.write(`[pa] unknown verb: ${verb}\n`);
97
101
  process.stderr.write(USAGE);
@@ -174,10 +178,21 @@ async function doStatus(opts = {}) {
174
178
  }
175
179
 
176
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.
177
187
  out.write('\nWorkspace:\n');
178
- 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`;
179
191
  const reachability = await probeWorkspace(probeUrl, opts.fetchImpl);
180
192
  out.write(` ${probeUrl}\n`);
193
+ if (runningPort !== null && runningPort !== workspacePort) {
194
+ out.write(` running port: ${runningPort} (configured: ${workspacePort})\n`);
195
+ }
181
196
  out.write(` status: ${reachability.status}`);
182
197
  if (reachability.detail) out.write(` (${reachability.detail})`);
183
198
  out.write('\n');
@@ -219,6 +234,37 @@ async function doStatus(opts = {}) {
219
234
  return allOk ? 0 : 2;
220
235
  }
221
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
+
222
268
  function resolveWorkspacePort(repoRoot, opts = {}) {
223
269
  if (opts.workspacePort) return opts.workspacePort;
224
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
+ }
@@ -97,23 +97,17 @@ line to run; do not run it for them (their shell rc is not our file to write).
97
97
  ### 5. workspaceOffer
98
98
 
99
99
  Ask if they want the local workspace started now. On yes, run
100
- `pnpm exec pa workspace start` in the background and print the URL from
101
- `operator/config/preferences.json:workspacePort` (default 4200).
102
-
103
- If the start fails because the configured port is already in use (the
104
- operator has something else on 4200, or the previous session did not
105
- shut down cleanly), do NOT stop the walkthrough. Instead:
106
-
107
- 1. Pick a free port at or above 4210 (probe 4210, 4211, 4212 ... in
108
- order until one is free).
109
- 2. Retry `pnpm exec pa workspace start --port N` with that port.
110
- 3. On success, update `operator/config/preferences.json` with
111
- `"workspacePort": N` so subsequent sessions start on the same port
112
- without asking again. Preserve the rest of the file exactly, and
113
- round-trip through `JSON.parse` / `JSON.stringify` with a two-space
114
- indent to keep the on-disk shape stable.
115
- 4. Tell the operator you moved the workspace off 4200 and named the
116
- 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.
117
111
 
118
112
  On no, note that `pnpm exec pa workspace start` is the verb whenever
119
113
  they want it.
@@ -205,6 +199,12 @@ identity each returns.
205
199
 
206
200
  ## Completion
207
201
 
208
- When every step is done or skipped, write `completedAt` and tell the operator
209
- onboarding is done. Give them one line: "You are ready. Ask me anything, or
210
- 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."
@@ -23,9 +23,12 @@
23
23
  import { readFile, stat } from 'node:fs/promises';
24
24
  import { readFileSync, existsSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
+ import { execSync } from 'node:child_process';
27
+ import { platform } from 'node:os';
26
28
 
27
29
  import { readPreferencesSync } from '../preferences.mjs';
28
30
  import { countStagedObservations } from './staging.mjs';
31
+ import { launchdLabelPrefix } from '../scheduler/install.mjs';
29
32
 
30
33
  /**
31
34
  * Default sweep cadence in hours. The scaffolded schedule fires every
@@ -104,6 +107,7 @@ export function readScheduleFile(repoRoot, paName) {
104
107
  schedule: parsed.schedule ?? null,
105
108
  host: parsed.host ?? null,
106
109
  id: parsed.id ?? null,
110
+ agent: parsed.agent ?? null,
107
111
  };
108
112
  } catch (err) {
109
113
  return { path, present: true, parseError: err.message || String(err) };
@@ -125,6 +129,103 @@ export function resolvePaName(repoRoot) {
125
129
  return null;
126
130
  }
127
131
 
132
+ /**
133
+ * Ask the schedule's declared backend whether it currently carries this
134
+ * schedule as a registered job. Returns a `{ registered, reason? }`
135
+ * envelope so callers can render an honest reason for a `false`.
136
+ *
137
+ * Semantics:
138
+ * - `{ registered: true }`: backend queried and confirms the job.
139
+ * - `{ registered: false }`: backend queried and reports no job, OR
140
+ * the declared backend is unavailable on this platform (in which
141
+ * case a `reason` such as `backend launchd unavailable on linux`
142
+ * is attached and the surface renders it).
143
+ * - `{ registered: null }`: cannot query at all (no id, unknown
144
+ * backend, systemd path we do not probe). The surface keeps the
145
+ * "registration unknown" line rather than misreporting a state.
146
+ *
147
+ * Costs one small subprocess per call (launchctl list, crontab -l,
148
+ * schtasks /Query). Called from the SessionStart hook so it must be
149
+ * cheap; each supported backend's probe is O(1) or O(lines in crontab).
150
+ *
151
+ * Backend probes:
152
+ * - launchd: `launchctl list <label>` (exit 0 = registered) on darwin.
153
+ * Off darwin the backend is unavailable → not registered here, with
154
+ * a reason. Label format is `com.<agent||'dave'>.<id>`.
155
+ * - cron: `crontab -l` contains `# dave-<id>` (the tag prefix is a
156
+ * load-bearing invariant on the removal path; changing it would
157
+ * break existing installs — see install.mjs::buildCronLine and the
158
+ * 0.1.50 release brief item 5). If a future release switches the
159
+ * tag to `${agent}-<id>` this probe will need to match either form.
160
+ * Cron is treated as available on any POSIX host; a missing crontab
161
+ * binary surfaces as `not registered` rather than platform-unavailable.
162
+ * - schtasks: `schtasks.exe /Query /TN "<agent>-<id>"` on win32. Off
163
+ * win32 the backend is unavailable → not registered here, with a
164
+ * reason.
165
+ * - systemd + unknown backends: not implemented; returns
166
+ * `{ registered: null }` so the surface degrades to "unknown"
167
+ * rather than misreporting a state we cannot verify.
168
+ *
169
+ * @param {object} schedule as returned by readScheduleFile (present:true branch)
170
+ * @param {object} [deps]
171
+ * @param {(cmd: string, opts?: object) => string|Buffer} [deps.execSync]
172
+ * @param {() => string} [deps.platform]
173
+ * @returns {{ registered: boolean|null, reason?: string }}
174
+ */
175
+ export function checkBackendRegistered(schedule, deps = {}) {
176
+ if (!schedule || !schedule.present || !schedule.id) return { registered: null };
177
+ const exec = deps.execSync || execSync;
178
+ const plat = deps.platform || platform;
179
+ const backend = schedule.backend;
180
+ const id = schedule.id;
181
+ try {
182
+ if (backend === 'launchd') {
183
+ const p = plat();
184
+ if (p !== 'darwin') {
185
+ return { registered: false, reason: `backend launchd unavailable on ${p}` };
186
+ }
187
+ const label = `${launchdLabelPrefix(schedule.agent)}${id}`;
188
+ try {
189
+ exec(`launchctl list ${label}`, { stdio: 'pipe' });
190
+ return { registered: true };
191
+ } catch {
192
+ return { registered: false };
193
+ }
194
+ }
195
+ if (backend === 'cron') {
196
+ let out;
197
+ try {
198
+ out = String(exec('crontab -l 2>/dev/null', { stdio: ['ignore', 'pipe', 'ignore'] }));
199
+ } catch {
200
+ // exit 1 with an empty crontab is the "no jobs" case, not a
201
+ // missing binary. Treat both as "not registered" (returning
202
+ // false rather than null keeps the surface actionable).
203
+ return { registered: false };
204
+ }
205
+ return { registered: out.includes(`# dave-${id}`) };
206
+ }
207
+ if (backend === 'schtasks') {
208
+ const p = plat();
209
+ if (p !== 'win32') {
210
+ return { registered: false, reason: `backend schtasks unavailable on ${p}` };
211
+ }
212
+ const taskName = `${schedule.agent || 'dave'}-${id}`;
213
+ try {
214
+ exec(`schtasks.exe /Query /TN "${taskName}"`, { stdio: 'pipe' });
215
+ return { registered: true };
216
+ } catch {
217
+ return { registered: false };
218
+ }
219
+ }
220
+ // systemd + unknown backends: the surface prefers "unknown" over
221
+ // "unregistered" here — a false negative would generate a permanent
222
+ // warning on hosts using a supported-but-unprobed backend.
223
+ return { registered: null };
224
+ } catch {
225
+ return { registered: null };
226
+ }
227
+ }
228
+
128
229
  /**
129
230
  * Compose the full distillation-health object for a given operator repo.
130
231
  *
@@ -152,15 +253,34 @@ export async function computeDistillationHealth({
152
253
  cadenceHours = DEFAULT_CADENCE_HOURS,
153
254
  staleMultiplier = DEFAULT_STALE_MULTIPLIER,
154
255
  now = new Date(),
256
+ checkRegistered = checkBackendRegistered,
257
+ hqMode = null,
155
258
  } = {}) {
156
259
  if (!repoRoot) throw new Error('computeDistillationHealth: repoRoot is required');
157
260
  const resolvedName = paName ?? resolvePaName(repoRoot);
158
261
  const warnings = [];
159
262
  let ok = true;
160
263
 
264
+ // HQ-mode signal: preferences.library.mode === 'remote' means the
265
+ // remote library service owns distillation, and there is no local
266
+ // schedule to install. Callers can inject `hqMode` for tests; when
267
+ // omitted we resolve from preferences (best-effort, never throws —
268
+ // a broken preferences file is a separate warning class).
269
+ let hq = hqMode;
270
+ if (hq === null) {
271
+ try {
272
+ const prefs = readPreferencesSync(repoRoot);
273
+ hq = prefs && prefs.library && prefs.library.mode === 'remote';
274
+ } catch { hq = false; }
275
+ }
276
+
161
277
  // Schedule file — presence is a hard requirement (without it, no
162
278
  // scheduled sweep runs, ever). Missing is a warning, not a throw:
163
279
  // the surface still needs to render for pa status.
280
+ //
281
+ // In HQ mode the schedule does not live on the local host at all;
282
+ // the remote library service runs the sweep, so a missing schedule
283
+ // file is expected and does NOT warn.
164
284
  let schedule;
165
285
  if (!resolvedName) {
166
286
  schedule = { present: false, path: null };
@@ -169,10 +289,12 @@ export async function computeDistillationHealth({
169
289
  } else {
170
290
  schedule = readScheduleFile(repoRoot, resolvedName);
171
291
  if (!schedule.present) {
172
- warnings.push(
173
- `no distillation schedule at schedules/${distillationScheduleFilename(resolvedName)} — run /onboard to install one`,
174
- );
175
- ok = false;
292
+ if (!hq) {
293
+ warnings.push(
294
+ `no distillation schedule at schedules/${distillationScheduleFilename(resolvedName)} — run /onboard to install one`,
295
+ );
296
+ ok = false;
297
+ }
176
298
  } else if (schedule.parseError) {
177
299
  warnings.push(`distillation schedule file is corrupt: ${schedule.parseError}`);
178
300
  ok = false;
@@ -182,6 +304,32 @@ export async function computeDistillationHealth({
182
304
  }
183
305
  }
184
306
 
307
+ // Backend registration check — a scaffolded file the caller never
308
+ // installed means the sweep will never fire. Distinguish
309
+ // "installed" from "scaffolded, not registered" so the surface can
310
+ // point at `scheduler install ... --force-host` when the fix is one
311
+ // command away.
312
+ if (schedule && schedule.present && !schedule.parseError && schedule.enabled !== false) {
313
+ const raw = checkRegistered(schedule);
314
+ // Accept either the object envelope `{ registered, reason? }` or the
315
+ // legacy bare boolean|null (kept for injected stubs in older tests).
316
+ const result = raw && typeof raw === 'object' && 'registered' in raw
317
+ ? raw
318
+ : { registered: raw };
319
+ schedule.registered = result.registered;
320
+ schedule.registeredReason = result.reason || null;
321
+ if (result.registered === false) {
322
+ const reasonSuffix = result.reason ? ` (${result.reason})` : '';
323
+ warnings.push(
324
+ `distillation schedule scaffolded but not registered with ${schedule.backend || 'the backend'}${reasonSuffix} — run \`scheduler install schedules/${distillationScheduleFilename(resolvedName)} --force-host\` to register`,
325
+ );
326
+ ok = false;
327
+ }
328
+ } else if (schedule && schedule.present) {
329
+ schedule.registered = null;
330
+ schedule.registeredReason = null;
331
+ }
332
+
185
333
  // Last-run ledger — null (never run), an error, or the last record.
186
334
  const lastRun = await readLastLedgerEntry(repoRoot);
187
335
  let annotatedLastRun = lastRun;
@@ -224,6 +372,7 @@ export async function computeDistillationHealth({
224
372
  stagingCount,
225
373
  warnings,
226
374
  ok,
375
+ hqMode: !!hq,
227
376
  generatedAt: now.toISOString(),
228
377
  };
229
378
  }
@@ -253,8 +402,23 @@ export function formatDuration(seconds) {
253
402
  */
254
403
  export function renderHealthLine(health) {
255
404
  const parts = [];
256
- if (health.schedule?.present && health.schedule.enabled !== false) {
257
- parts.push(`schedule: installed (${health.schedule.backend || 'backend?'}, ${health.schedule.schedule || '?'})`);
405
+ // HQ-mode instances have no local schedule at all — the remote
406
+ // library service owns distillation. Render that state distinctly so
407
+ // the surface does not scream "MISSING" at an instance whose design
408
+ // is to have no local schedule (item 6, 0.1.50).
409
+ if (health.hqMode && !health.schedule?.present) {
410
+ parts.push('schedule: remote (library service)');
411
+ } else if (health.schedule?.present && health.schedule.enabled !== false) {
412
+ // Three states: installed / scaffolded, not registered / installed
413
+ // (registration unknown — happens on backends we cannot probe).
414
+ if (health.schedule.registered === false) {
415
+ const reasonSuffix = health.schedule.registeredReason
416
+ ? `; ${health.schedule.registeredReason}`
417
+ : '';
418
+ parts.push(`schedule: scaffolded, not registered (${health.schedule.backend || 'backend?'}, ${health.schedule.schedule || '?'}${reasonSuffix})`);
419
+ } else {
420
+ parts.push(`schedule: installed (${health.schedule.backend || 'backend?'}, ${health.schedule.schedule || '?'})`);
421
+ }
258
422
  } else if (health.schedule?.present === false) {
259
423
  parts.push('schedule: MISSING');
260
424
  } else if (health.schedule?.enabled === false) {
@@ -38,6 +38,8 @@ import { basename, join } from 'node:path';
38
38
  import { resolvePrereqs } from './prereqs.mjs';
39
39
  import { countStagedObservations } from './staging.mjs';
40
40
  import { buildDistillPrompt, claudeArgs, resolveDistillModel } from './prompt.mjs';
41
+ import { buildBacklinkIndex } from '../knowledge/retrieval/backlink-indexer.mjs';
42
+ import { buildKnowledgeGraph } from '../knowledge/retrieval/graph-builder.mjs';
41
43
 
42
44
  function log(line, { stderr = process.stderr } = {}) {
43
45
  stderr.write(`[distill] ${line}\n`);
@@ -225,6 +227,23 @@ export async function runDistill({
225
227
  // so a deletion at commit time is invisible to git.
226
228
  await invalidateRetrievalArtefacts(repoRoot, stderr);
227
229
 
230
+ // Durable post-sweep rebuild (item 8, 0.1.50): same two builders
231
+ // `pa knowledge` uses on demand, in the same order (backlinks first,
232
+ // graph reads them). Landing them on disk right after invalidation
233
+ // means the SessionStart hook and every desk-cache path see the
234
+ // artefact without paying for an on-demand build on the next boot.
235
+ // NEVER fail the sweep on a builder error: a durable artefact is a
236
+ // convenience, on-demand rebuild remains the fallback.
237
+ try {
238
+ await buildBacklinkIndex({ root: repoRoot });
239
+ await buildKnowledgeGraph({ root: repoRoot });
240
+ } catch (e) {
241
+ log(
242
+ `warning: post-sweep retrieval rebuild failed (${e.message}); on-demand fallback remains`,
243
+ { stderr },
244
+ );
245
+ }
246
+
228
247
  // Step 4b — append success audit. Sync will pick up any file changes
229
248
  // the librarian produced on its next 30-minute tick.
230
249
  await writeAudit(runsLog, {
@@ -0,0 +1,104 @@
1
+ // find.mjs — shared sync resolver for the operator instance root.
2
+ //
3
+ // Promoted from packages/workspace/src/repo-root.mjs so scheduler,
4
+ // knowledge retrieval, and every other core consumer resolves the
5
+ // instance root through ONE primitive instead of ad-hoc walks that
6
+ // each get the pnpm-store trap wrong on their own.
7
+ //
8
+ // Under the published tarball layout, every dist package lives at
9
+ // `node_modules/@stravica-ai/digital-operator-pa/dist/engineering-pa-*/`.
10
+ // A `resolve(__dirname, '..', '..', '..')` walk from `src/` therefore
11
+ // lands inside the digital-operator-pa package directory (a pnpm-store
12
+ // hard-link), NOT at the operator repo root — silently misdirecting
13
+ // every file the caller derives from that path (schedules, retrieval
14
+ // artefacts, `.ws.pid`, `.ws.log`, preferences overlay reads).
15
+ //
16
+ // Precedence:
17
+ //
18
+ // 1. `DAVE_REPO_ROOT` or `PA_REPO_ROOT` env override (tests + operator-set).
19
+ // 2. Walk up from `process.cwd()` looking for a marker that identifies an
20
+ // operator instance root:
21
+ // - `.git` (present in every scaffolded instance — pa-init runs `git
22
+ // init` at scaffold time).
23
+ // - `operator/config/preferences.json` (the canonical instance marker;
24
+ // matches instances that lost their `.git` for any reason).
25
+ // - `pnpm-workspace.yaml` (matches monorepo checkouts, e.g. this
26
+ // engineering-pa-dev repo itself, so package-local tests still work).
27
+ // 3. Fall back to `process.cwd()` so pre-scaffold and dry-run flows still
28
+ // have a stable value to render.
29
+ //
30
+ // The `startDir` argument is injectable for tests and callers that need to
31
+ // resolve from a directory other than `process.cwd()`. Prod callers use
32
+ // the default.
33
+
34
+ import { existsSync } from 'node:fs';
35
+ import { join, resolve } from 'node:path';
36
+
37
+ const MARKERS = [
38
+ '.git',
39
+ join('operator', 'config', 'preferences.json'),
40
+ 'pnpm-workspace.yaml',
41
+ ];
42
+
43
+ /**
44
+ * Walk up from `start` looking for the first directory that contains any
45
+ * marker in {@link MARKERS}. Returns the directory path, or null when the
46
+ * walk reaches the filesystem root without a hit.
47
+ *
48
+ * @param {string} start
49
+ * @returns {string|null}
50
+ */
51
+ export function walkUpForMarker(start) {
52
+ let d = resolve(start);
53
+ while (d && d !== resolve(d, '..')) {
54
+ for (const marker of MARKERS) {
55
+ if (existsSync(join(d, marker))) return d;
56
+ }
57
+ d = resolve(d, '..');
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // Process-level guard so the fallback warning fires at most once per
63
+ // process, no matter how many callers hit the resolver. Tests can reset
64
+ // via the exported `_resetFallbackWarningForTests` helper below.
65
+ let _fallbackWarned = false;
66
+
67
+ /** Test-only reset for the once-per-process fallback warning latch. */
68
+ export function _resetFallbackWarningForTests() {
69
+ _fallbackWarned = false;
70
+ }
71
+
72
+ /**
73
+ * Resolve the operator instance root. Deterministic and synchronous; safe
74
+ * to call at module top-level. See the header comment for the full
75
+ * precedence rule.
76
+ *
77
+ * When no marker is found AND no env override is set, emit ONE stderr
78
+ * warning naming the cwd we're falling back to and the two overrides that
79
+ * would silence it. The walk still returns cwd so pre-scaffold and dry-run
80
+ * flows keep working — the warning is a diagnostic, not an error.
81
+ *
82
+ * @param {object} [opts]
83
+ * @param {string} [opts.startDir=process.cwd()] Directory to start the walk from.
84
+ * @param {NodeJS.ProcessEnv} [opts.env=process.env] Env source (tests inject).
85
+ * @param {(msg: string) => void} [opts.warn] Optional warn sink (tests inject).
86
+ * @returns {string} Absolute path.
87
+ */
88
+ export function findRepoRootSync(opts = {}) {
89
+ const env = opts.env || process.env;
90
+ const startDir = opts.startDir || process.cwd();
91
+ const envRoot = env.DAVE_REPO_ROOT || env.PA_REPO_ROOT;
92
+ if (envRoot) return resolve(envRoot);
93
+ const found = walkUpForMarker(startDir);
94
+ if (found) return found;
95
+ const fallback = resolve(startDir);
96
+ if (!_fallbackWarned) {
97
+ _fallbackWarned = true;
98
+ const warn = opts.warn || ((m) => process.stderr.write(m + '\n'));
99
+ warn(
100
+ `workspace: no instance marker (.git, operator/config/preferences.json, pnpm-workspace.yaml) found walking up from ${fallback}; falling back to cwd. Set DAVE_REPO_ROOT or PA_REPO_ROOT to silence.`,
101
+ );
102
+ }
103
+ return fallback;
104
+ }
@@ -29,9 +29,14 @@ import { join, resolve, dirname, relative } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { buildFileIndex, extractLinks, scanSourcesFor, toPosix, walkFiles } from './links.mjs';
31
31
  import { writeJsonAtomic } from './write-atomic.mjs';
32
+ import { findRepoRootSync } from '../../instance-root/find.mjs';
32
33
 
33
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
- const DEFAULT_ROOT = resolve(__dirname, '../../../..');
35
+ const DEFAULT_ROOT = process.env.DAVE_ROOT
36
+ ? resolve(process.env.DAVE_ROOT)
37
+ : process.env.PA_ROOT
38
+ ? resolve(process.env.PA_ROOT)
39
+ : findRepoRootSync();
35
40
 
36
41
  const USAGE = `
37
42
  backlink-indexer — Build a backlink index from markdown internal links
@@ -29,9 +29,14 @@ import {
29
29
  walkFiles,
30
30
  } from './links.mjs';
31
31
  import { writeJsonAtomic } from './write-atomic.mjs';
32
+ import { findRepoRootSync } from '../../instance-root/find.mjs';
32
33
 
33
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
- const DEFAULT_ROOT = resolve(__dirname, '../../../..');
35
+ const DEFAULT_ROOT = process.env.DAVE_ROOT
36
+ ? resolve(process.env.DAVE_ROOT)
37
+ : process.env.PA_ROOT
38
+ ? resolve(process.env.PA_ROOT)
39
+ : findRepoRootSync();
35
40
 
36
41
  const USAGE = `
37
42
  graph-builder — Build a knowledge graph from markdown links, index hubs and backlinks