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