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