@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.
- package/dist/engineering-pa-cli/src/pa/cli.mjs +47 -1
- package/dist/engineering-pa-cli/src/pa/onboard.mjs +114 -0
- package/dist/engineering-pa-core/docs/commands/onboard.md +20 -20
- package/dist/engineering-pa-core/src/distill/health.mjs +170 -6
- package/dist/engineering-pa-core/src/distill/run.mjs +19 -0
- package/dist/engineering-pa-core/src/instance-root/find.mjs +104 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +6 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +6 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +4 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +6 -1
- package/dist/engineering-pa-core/src/scheduler/install.mjs +41 -4
- package/dist/engineering-pa-core/src/scheduler/list.mjs +34 -13
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -1
- package/dist/engineering-pa-core-extensions-atlassian/package.json +3 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +4 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +4 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +6 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +7 -3
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +9 -3
- package/dist/engineering-pa-scaffold/src/generator.mjs +3 -1
- package/dist/engineering-pa-workspace/app/notes.html +1 -1
- package/dist/engineering-pa-workspace/app/project-detail.html +17 -1
- package/dist/engineering-pa-workspace/app/projects.html +1 -1
- package/dist/engineering-pa-workspace/app/tasks.html +1 -1
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +2 -2
- package/dist/engineering-pa-workspace/src/repo-root.mjs +13 -100
- package/dist/engineering-pa-workspace/src/server.mjs +2 -2
- package/dist/engineering-pa-workspace/src/ws.mjs +79 -10
- package/docs/onboarding-solo.md +1 -1
- 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
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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 =
|
|
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 =
|
|
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
|