@stravica-ai/digital-operator-pa 0.1.43 → 0.1.45
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 +4 -2
- package/dist/engineering-pa-cli/src/sync/cli.mjs +2 -2
- package/dist/engineering-pa-cli/src/sync/status-record.mjs +347 -0
- package/dist/engineering-pa-core/docs/commands/onboard.md +119 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-server.md +1 -1
- package/dist/engineering-pa-library-cli/README.md +59 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +41 -1
- package/dist/engineering-pa-library-cli/src/up-merge/github.mjs +103 -0
- package/dist/engineering-pa-library-cli/src/up-merge/scope.mjs +95 -0
- package/dist/engineering-pa-library-cli/src/verbs/up-merge.mjs +290 -0
- package/dist/engineering-pa-scaffold/index.mjs +1 -1
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +5 -1
- package/dist/engineering-pa-scaffold/src/generator.mjs +85 -2
- package/dist/engineering-pa-workspace/README.md +6 -4
- package/dist/engineering-pa-workspace/docs/DAEMON.md +19 -4
- package/dist/engineering-pa-workspace/docs/status-mon.md +1 -1
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +289 -78
- package/dist/engineering-pa-workspace/src/supervisor.mjs +74 -13
- package/dist/engineering-pa-workspace/src/unit-name.mjs +113 -0
- package/dist/engineering-pa-workspace/src/ws.mjs +17 -5
- package/docs/onboarding-solo.md +45 -130
- package/package.json +1 -1
|
@@ -360,9 +360,11 @@ Usage:
|
|
|
360
360
|
Bind host defaults to loopback (127.0.0.1). Use --host <addr> (or the
|
|
361
361
|
WORKSPACE_HOST env var) to expose the workspace on another interface.
|
|
362
362
|
|
|
363
|
-
When the workspace is supervised by launchd (~/Library/LaunchAgents/com
|
|
364
|
-
or systemd (~/.config/systemd/user
|
|
363
|
+
When the workspace is supervised by launchd (~/Library/LaunchAgents/com.<paName>.workspace.plist)
|
|
364
|
+
or systemd (~/.config/systemd/user/<paName>-workspace.service), \`stop\` is refused and \`restart\`
|
|
365
365
|
delegates to the supervisor — this is the in-place version-pickup path after \`pnpm update\`.
|
|
366
|
+
Unit labels are derived from operator/config/preferences.json:paName; the legacy
|
|
367
|
+
com.dave.workspace / dave-workspace.service is still recognised for pre-0.1.44 installs.
|
|
366
368
|
|
|
367
369
|
Exit codes (tooling standard §6):
|
|
368
370
|
0 ok | 1 usage error | 2 operational failure | 3 transport/IO failure
|
|
@@ -18,8 +18,8 @@ import { existsSync } from 'node:fs';
|
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { parseArgs } from 'node:util';
|
|
20
20
|
|
|
21
|
-
import { runSync } from './run.mjs';
|
|
22
21
|
import { defaultRunner } from './runner.mjs';
|
|
22
|
+
import { runSyncWithStatus } from './status-record.mjs';
|
|
23
23
|
|
|
24
24
|
const USAGE = `estate-sync — single-writer-per-host git sync for ESTATE-NATIVE content.
|
|
25
25
|
|
|
@@ -134,7 +134,7 @@ async function route(argv) {
|
|
|
134
134
|
await mkdir(logDir, { recursive: true });
|
|
135
135
|
const runsLog = join(logDir, 'runs.jsonl');
|
|
136
136
|
const lockPath = join(repoRoot, '.cache/estate-sync/.lock');
|
|
137
|
-
const code = await
|
|
137
|
+
const code = await runSyncWithStatus({
|
|
138
138
|
repoRoot,
|
|
139
139
|
logDir,
|
|
140
140
|
runsLog,
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// estate-sync — synced end-of-pass status record.
|
|
2
|
+
//
|
|
3
|
+
// After every real pass, estate-sync writes a tiny per-host status file
|
|
4
|
+
// at `<repoRoot>/agents/estate-sync/<paName>/<host>.json`. This path is
|
|
5
|
+
// TRACKED and — critically — sits OUTSIDE every registry mount `to`
|
|
6
|
+
// subtree, so estate-sync's own registry-scoped commit stages it (a
|
|
7
|
+
// path inside `agents/<paName>/` would be silently excluded by the
|
|
8
|
+
// mount filter and never leave the writing host). The consequence: the
|
|
9
|
+
// pass's own commit + push carries every host's most recent pass
|
|
10
|
+
// result into every clone. A reader on any host (e.g. the HQ boot-
|
|
11
|
+
// context hook) can then flag a stuck cron on any other host at
|
|
12
|
+
// session boot without ssh or a separate publisher.
|
|
13
|
+
//
|
|
14
|
+
// Location choice: `agents/estate-sync/` is not under any mount `to`
|
|
15
|
+
// path in the current registry (`operator/registry/repos.json` mounts
|
|
16
|
+
// `agents/<name>/`, one per agent, not `agents/` itself). Every host
|
|
17
|
+
// therefore commits its own file directly, no per-host registry
|
|
18
|
+
// change needed.
|
|
19
|
+
//
|
|
20
|
+
// Design notes:
|
|
21
|
+
// - Overwrite semantics: one JSON file per host, small (< 1 KB). The
|
|
22
|
+
// writer never appends; the previous pass's record is replaced.
|
|
23
|
+
// - The record is derived from the audit record estate-sync already
|
|
24
|
+
// writes to runs.jsonl (same source of truth) — so a change to the
|
|
25
|
+
// audit vocabulary is picked up here for free.
|
|
26
|
+
// - noop-locked passes DO NOT write a status record: the pass that is
|
|
27
|
+
// actually running (and holding the lock) will write its own record
|
|
28
|
+
// when it finishes, and a concurrent overwrite from a lock contender
|
|
29
|
+
// would race with it. Contenders exit silently.
|
|
30
|
+
// - The `agents/estate-sync/<paName>/` directory is created on first
|
|
31
|
+
// write. Failure to write fails silently — this is a status
|
|
32
|
+
// side-channel and must never block the sync exit code.
|
|
33
|
+
// - Callers wrap runSync via `runSyncWithStatus`, which reads the last
|
|
34
|
+
// line of runs.jsonl to derive the terminal record. This means the
|
|
35
|
+
// writer is layered ON TOP of runSync rather than braided into every
|
|
36
|
+
// one of its exit paths — the run.mjs surface stays untouched.
|
|
37
|
+
//
|
|
38
|
+
// Absence-of-work: a fresh file dated after `now - 6h` on every host is
|
|
39
|
+
// the healthy signal; the reader flags anything older or with alertLevel
|
|
40
|
+
// > 'ok'. The 6h window is chosen against the 30-minute cron cadence,
|
|
41
|
+
// twelve missed ticks is loud enough to warrant a session-boot line.
|
|
42
|
+
|
|
43
|
+
import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises';
|
|
44
|
+
import { hostname as osHostname } from 'node:os';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
|
|
47
|
+
import { runSync } from './run.mjs';
|
|
48
|
+
import { revParseSha } from './git.mjs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Audit statuses that count as a healthy pass. Everything else
|
|
52
|
+
* (`fail-*`, unknown vocab, missing status) maps to `failed` and the
|
|
53
|
+
* reader is expected to flag it. `noop-locked` never reaches the
|
|
54
|
+
* composer (see runSyncWithStatus), but is listed here for parity so a
|
|
55
|
+
* future direct caller lands in the same place.
|
|
56
|
+
*/
|
|
57
|
+
const OK_AUDIT_STATUSES = new Set([
|
|
58
|
+
'ok',
|
|
59
|
+
'noop-nothing-to-do',
|
|
60
|
+
'noop-locked',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const ALERT_RANK = { ok: -1, warn: 0, error: 1, 'needs-human': 2 };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compose the tracked status record from an audit record plus context.
|
|
67
|
+
* Pure — no clock, no filesystem. Tested directly.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} input
|
|
70
|
+
* @param {object} input.audit audit record as written to runs.jsonl
|
|
71
|
+
* @param {string} input.paName resolved paName (e.g. 'dave')
|
|
72
|
+
* @param {string} input.host resolved hostname (os.hostname())
|
|
73
|
+
* @param {string|null} input.headBefore HEAD sha at start of pass (or null)
|
|
74
|
+
* @param {string|null} input.headAfter HEAD sha at end of pass (or null)
|
|
75
|
+
* @param {number} [input.pendingCount] count of self-heal pending entries
|
|
76
|
+
* @returns {{ host:string, paName:string, finishedAt:string, status:'ok'|'failed', alertLevel:'ok'|'warn'|'error'|'needs-human', attempts:number, lastError:(string|null), headBefore:(string|null), headAfter:(string|null), pending:number, auditStatus:string }}
|
|
77
|
+
*/
|
|
78
|
+
export function composeStatusRecord({
|
|
79
|
+
audit,
|
|
80
|
+
paName,
|
|
81
|
+
host,
|
|
82
|
+
headBefore = null,
|
|
83
|
+
headAfter = null,
|
|
84
|
+
pendingCount = 0,
|
|
85
|
+
}) {
|
|
86
|
+
const auditStatus = typeof audit?.status === 'string' ? audit.status : 'unknown';
|
|
87
|
+
const status = OK_AUDIT_STATUSES.has(auditStatus) ? 'ok' : 'failed';
|
|
88
|
+
const alertLevel = deriveAlertLevel(audit, auditStatus);
|
|
89
|
+
const attempts = deriveAttempts(audit);
|
|
90
|
+
const lastError = deriveLastError(audit);
|
|
91
|
+
return {
|
|
92
|
+
host,
|
|
93
|
+
paName,
|
|
94
|
+
finishedAt: typeof audit?.ts === 'string' ? audit.ts : '',
|
|
95
|
+
status,
|
|
96
|
+
alertLevel,
|
|
97
|
+
attempts,
|
|
98
|
+
lastError,
|
|
99
|
+
headBefore: typeof headBefore === 'string' ? headBefore : null,
|
|
100
|
+
headAfter: typeof headAfter === 'string' ? headAfter : null,
|
|
101
|
+
pending: Number.isFinite(pendingCount) ? pendingCount : 0,
|
|
102
|
+
auditStatus,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deriveAlertLevel(audit, auditStatus) {
|
|
107
|
+
// Prefer the highest-ranked alert level across queued self-heal
|
|
108
|
+
// entries when the audit carries them (needs-human > error > warn).
|
|
109
|
+
if (Array.isArray(audit?.queued) && audit.queued.length > 0) {
|
|
110
|
+
let best = 'warn';
|
|
111
|
+
for (const q of audit.queued) {
|
|
112
|
+
const lvl = typeof q?.alertLevel === 'string' ? q.alertLevel : 'warn';
|
|
113
|
+
if ((ALERT_RANK[lvl] ?? -1) > (ALERT_RANK[best] ?? -1)) best = lvl;
|
|
114
|
+
}
|
|
115
|
+
return best;
|
|
116
|
+
}
|
|
117
|
+
// Otherwise map from the status vocabulary.
|
|
118
|
+
switch (auditStatus) {
|
|
119
|
+
case 'fail-rebase-needs-human':
|
|
120
|
+
return 'needs-human';
|
|
121
|
+
case 'fail-rebase':
|
|
122
|
+
case 'fail-rebase-stuck':
|
|
123
|
+
case 'fail-push':
|
|
124
|
+
case 'fail-git':
|
|
125
|
+
case 'fail-cli-bootstrap':
|
|
126
|
+
return 'error';
|
|
127
|
+
case 'fail-rebase-queued':
|
|
128
|
+
return 'warn';
|
|
129
|
+
default:
|
|
130
|
+
return 'ok';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function deriveAttempts(audit) {
|
|
135
|
+
if (Array.isArray(audit?.queued) && audit.queued.length > 0) {
|
|
136
|
+
let max = 0;
|
|
137
|
+
for (const q of audit.queued) {
|
|
138
|
+
const n = typeof q?.attemptCount === 'number' ? q.attemptCount : 0;
|
|
139
|
+
if (n > max) max = n;
|
|
140
|
+
}
|
|
141
|
+
return max;
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function deriveLastError(audit) {
|
|
147
|
+
const reason = audit?.reason;
|
|
148
|
+
if (typeof reason !== 'string' || reason.length === 0) return null;
|
|
149
|
+
return reason.split(/\r?\n/)[0].slice(0, 200);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Write the status record to
|
|
154
|
+
* `<repoRoot>/agents/estate-sync/<paName>/<host>.json`. Parent dirs are
|
|
155
|
+
* created; the file is fully overwritten. Returns the path written.
|
|
156
|
+
* Failure to write is thrown to the caller — callers wrap in try/catch
|
|
157
|
+
* and never block on this.
|
|
158
|
+
*
|
|
159
|
+
* @param {{ repoRoot:string, paName:string, host:string, record:object }} opts
|
|
160
|
+
* @returns {Promise<string>}
|
|
161
|
+
*/
|
|
162
|
+
export async function writeStatusRecord({ repoRoot, paName, host, record }) {
|
|
163
|
+
const dir = join(repoRoot, 'agents', 'estate-sync', sanitiseSegment(paName));
|
|
164
|
+
await mkdir(dir, { recursive: true });
|
|
165
|
+
const path = join(dir, `${sanitiseSegment(host)}.json`);
|
|
166
|
+
await writeFile(path, JSON.stringify(record, null, 2) + '\n');
|
|
167
|
+
return path;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read paName from preferences.json with an optional preferences.local.json
|
|
172
|
+
* overlay (overlay wins). Returns null on any failure — the status
|
|
173
|
+
* writer is a side-channel and a missing paName just means the record
|
|
174
|
+
* is skipped, not that estate-sync itself fails.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} repoRoot
|
|
177
|
+
* @returns {Promise<string|null>}
|
|
178
|
+
*/
|
|
179
|
+
export async function readPaName(repoRoot) {
|
|
180
|
+
const basePath = join(repoRoot, 'operator/config/preferences.json');
|
|
181
|
+
const overlayPath = join(repoRoot, 'operator/config/preferences.local.json');
|
|
182
|
+
let base = {};
|
|
183
|
+
let overlay = {};
|
|
184
|
+
try { base = JSON.parse(await readFile(basePath, 'utf8')); } catch { /* ignore */ }
|
|
185
|
+
try { overlay = JSON.parse(await readFile(overlayPath, 'utf8')); } catch { /* ignore */ }
|
|
186
|
+
const value = overlay?.paName ?? base?.paName ?? null;
|
|
187
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `os.hostname()` with the injection seam runSync already uses for
|
|
192
|
+
* registry-scope tests. Empty results fall back to `unknown-host`.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveHost(hostnameFn) {
|
|
195
|
+
try {
|
|
196
|
+
const h = (hostnameFn || osHostname)();
|
|
197
|
+
return typeof h === 'string' && h.length > 0 ? h : 'unknown-host';
|
|
198
|
+
} catch {
|
|
199
|
+
return 'unknown-host';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read the last non-empty line of runs.jsonl and parse it as JSON.
|
|
205
|
+
* Returns null if the file is missing, empty, or the last line does not
|
|
206
|
+
* parse. Callers only care about the terminal record for the pass they
|
|
207
|
+
* just ran; earlier entries are irrelevant here.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} runsLog
|
|
210
|
+
* @returns {Promise<object|null>}
|
|
211
|
+
*/
|
|
212
|
+
export async function readLastAuditRecord(runsLog) {
|
|
213
|
+
let text;
|
|
214
|
+
try {
|
|
215
|
+
text = await readFile(runsLog, 'utf8');
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err && err.code === 'ENOENT') return null;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
const lines = text.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
221
|
+
if (lines.length === 0) return null;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Count entries in the self-heal pending queue directory. Returns 0 on
|
|
231
|
+
* missing dir. Directory entries starting with `.` (e.g. `.gitkeep`)
|
|
232
|
+
* are ignored, and sidecar `.json` files are counted alongside their
|
|
233
|
+
* data twins as a single pending — we count the DATA files only, i.e.
|
|
234
|
+
* entries whose name does not end in `.json`.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} pendingDir
|
|
237
|
+
* @returns {Promise<number>}
|
|
238
|
+
*/
|
|
239
|
+
export async function countPending(pendingDir) {
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = await readdir(pendingDir);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err && err.code === 'ENOENT') return 0;
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
return entries.filter((n) => !n.startsWith('.') && !n.endsWith('.json')).length;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Wrap `runSync` so that after each real (non-dry-run) pass the terminal
|
|
252
|
+
* audit record is mirrored to a tracked per-host status file. Signature
|
|
253
|
+
* is a superset of runSync — every existing runSync caller keeps working
|
|
254
|
+
* unchanged when swapped for this wrapper.
|
|
255
|
+
*
|
|
256
|
+
* Injection seams (all test-only; production defaults from prefs/env):
|
|
257
|
+
* - `paName` : override the paName that would otherwise come from
|
|
258
|
+
* preferences.json (used by tests and by callers who
|
|
259
|
+
* already resolved it).
|
|
260
|
+
* - `hostnameFn` : function returning the host string (already an
|
|
261
|
+
* injection seam on runSync itself, for scope tests).
|
|
262
|
+
* - `statusEnabled: false` disables the status write entirely (kept as
|
|
263
|
+
* a defensive off-switch; not user-facing).
|
|
264
|
+
*
|
|
265
|
+
* @param {object} opts every field runSync accepts, plus `paName`, `statusEnabled`.
|
|
266
|
+
* @returns {Promise<number>}
|
|
267
|
+
*/
|
|
268
|
+
export async function runSyncWithStatus(opts) {
|
|
269
|
+
const {
|
|
270
|
+
repoRoot,
|
|
271
|
+
runsLog,
|
|
272
|
+
runner,
|
|
273
|
+
dryRun = false,
|
|
274
|
+
hostnameFn,
|
|
275
|
+
paName: paNameOverride,
|
|
276
|
+
statusEnabled = true,
|
|
277
|
+
stderr = process.stderr,
|
|
278
|
+
} = opts;
|
|
279
|
+
|
|
280
|
+
// Resolve paName + host early; if paName is unresolvable we skip the
|
|
281
|
+
// status write but still run the sync. Host is safe-defaulted.
|
|
282
|
+
const paName = statusEnabled
|
|
283
|
+
? (paNameOverride || (repoRoot ? await readPaName(repoRoot) : null))
|
|
284
|
+
: null;
|
|
285
|
+
const host = resolveHost(hostnameFn);
|
|
286
|
+
|
|
287
|
+
// Capture HEAD BEFORE the pass — best-effort. A detached HEAD or an
|
|
288
|
+
// uninitialised repo yields null; the reader treats null as "unknown"
|
|
289
|
+
// and does not flag on it.
|
|
290
|
+
let headBefore = null;
|
|
291
|
+
if (paName && !dryRun && runner) {
|
|
292
|
+
headBefore = await safeHeadSha(runner, repoRoot);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const code = await runSync(opts);
|
|
296
|
+
|
|
297
|
+
// Skip status write for dry-run (nothing durable to report), for
|
|
298
|
+
// missing paName (nowhere to write), and for lock-contended passes
|
|
299
|
+
// (the pass holding the lock writes its own record; a contender would
|
|
300
|
+
// race).
|
|
301
|
+
if (!paName || dryRun) return code;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const audit = await readLastAuditRecord(runsLog);
|
|
305
|
+
if (!audit) return code;
|
|
306
|
+
if (audit.status === 'noop-locked') return code;
|
|
307
|
+
|
|
308
|
+
const headAfter = await safeHeadSha(runner, repoRoot);
|
|
309
|
+
const pendingCount = await countPending(join(repoRoot, '.cache/estate-sync/pending'));
|
|
310
|
+
const record = composeStatusRecord({
|
|
311
|
+
audit,
|
|
312
|
+
paName,
|
|
313
|
+
host,
|
|
314
|
+
headBefore,
|
|
315
|
+
headAfter,
|
|
316
|
+
pendingCount,
|
|
317
|
+
});
|
|
318
|
+
await writeStatusRecord({ repoRoot, paName, host, record });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
// Never block the sync exit code on a status-write failure. Log
|
|
321
|
+
// one line so the seam is visible in the cron output.
|
|
322
|
+
stderr.write(`[estate-sync] warning: failed to write synced status record: ${err && err.message ? err.message : err}\n`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return code;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function safeHeadSha(runner, repoRoot) {
|
|
329
|
+
try {
|
|
330
|
+
const res = await revParseSha(runner, { cwd: repoRoot, ref: 'HEAD' });
|
|
331
|
+
if (res && res.code === 0 && typeof res.sha === 'string' && res.sha.length > 0) {
|
|
332
|
+
return res.sha;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function sanitiseSegment(name) {
|
|
341
|
+
// File-system-safe path segment: keep alnum, dot, dash, underscore.
|
|
342
|
+
// Anything else collapses to `-`. Empty result falls back to
|
|
343
|
+
// `unknown`. Prevents a hostname or paName carrying a path separator
|
|
344
|
+
// from writing outside the intended directory.
|
|
345
|
+
const cleaned = String(name || '').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
346
|
+
return cleaned.length > 0 ? cleaned : 'unknown';
|
|
347
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: First-session onboarding walkthrough — verifies the install, offers optional setup (Atlassian, workspace, alias, profile intake), and marks each step complete.
|
|
3
|
+
when_to_use: >-
|
|
4
|
+
Run on the operator's first session in a fresh scaffolded PA instance. Also
|
|
5
|
+
re-runnable any time later: every step is idempotent and reads the current
|
|
6
|
+
state from `.session/onboarding.json`, so `/onboard` picks up where it left off
|
|
7
|
+
and skips steps already marked done.
|
|
8
|
+
allowed-tools: Bash, Read, Write, Edit
|
|
9
|
+
argument-hint: (no arguments)
|
|
10
|
+
context: inline
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# /onboard — first-session walkthrough
|
|
14
|
+
|
|
15
|
+
You are running the onboarding walkthrough for a freshly scaffolded PA instance.
|
|
16
|
+
The scaffold has already run `pnpm install` and `pa status` at scaffold time, so
|
|
17
|
+
the install is proven working. Your job is the second half: verify the runtime,
|
|
18
|
+
offer the optional setup steps the guide deliberately left for you, and hand
|
|
19
|
+
over. Every step below is idempotent. State lives in `.session/onboarding.json`
|
|
20
|
+
so re-running `/onboard` picks up where it left off.
|
|
21
|
+
|
|
22
|
+
## State file
|
|
23
|
+
|
|
24
|
+
Read `.session/onboarding.json` first. Shape:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
{
|
|
28
|
+
"version": 1,
|
|
29
|
+
"startedAt": "<iso>",
|
|
30
|
+
"steps": {
|
|
31
|
+
"verifyRuntime": "pending" | "done" | "skipped",
|
|
32
|
+
"verifyVersion": "pending" | "done" | "skipped",
|
|
33
|
+
"profileIntake": "pending" | "done" | "skipped",
|
|
34
|
+
"aliasOffer": "pending" | "done" | "skipped",
|
|
35
|
+
"workspaceOffer":"pending" | "done" | "skipped",
|
|
36
|
+
"atlassianOffer":"pending" | "done" | "skipped"
|
|
37
|
+
},
|
|
38
|
+
"completedAt": null | "<iso>"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If the file is missing, the scaffold did not seed it — flag that and stop.
|
|
43
|
+
|
|
44
|
+
If `completedAt` is set, tell the operator onboarding is already complete and
|
|
45
|
+
offer to re-run a specific step by name. Do not walk the whole flow again.
|
|
46
|
+
|
|
47
|
+
## Flow
|
|
48
|
+
|
|
49
|
+
Walk the pending steps in this order. After each, write the state file back
|
|
50
|
+
with the step marked `done` or `skipped`. Keep exchanges short: one question,
|
|
51
|
+
one answer, move on.
|
|
52
|
+
|
|
53
|
+
### 1. verifyRuntime
|
|
54
|
+
|
|
55
|
+
Run `pnpm exec pa environment` and read the resulting `.session/environment.json`.
|
|
56
|
+
Report Node version, pnpm version, git version, and workspace state in one line.
|
|
57
|
+
If Node is below 24 or pnpm below 9, name the gap and stop; the operator needs
|
|
58
|
+
to upgrade before continuing.
|
|
59
|
+
|
|
60
|
+
### 2. verifyVersion
|
|
61
|
+
|
|
62
|
+
Run `pnpm exec pa status` and capture the installed package version. Compare
|
|
63
|
+
against `npm view @stravica-ai/digital-operator-pa version`. On a match, mark
|
|
64
|
+
done and move on. On a mismatch, print both versions and the recovery line
|
|
65
|
+
(`pnpm install` inside the instance) — do not attempt the fix yourself.
|
|
66
|
+
|
|
67
|
+
### 3. profileIntake
|
|
68
|
+
|
|
69
|
+
Ask the operator four questions in one turn:
|
|
70
|
+
|
|
71
|
+
1. Your name and how you prefer to be addressed?
|
|
72
|
+
2. Your role (job title, or "solo", or a short description)?
|
|
73
|
+
3. Communication style you want (formal, casual, direct, verbose)?
|
|
74
|
+
4. Any topic areas the PA should be strong on from day one?
|
|
75
|
+
|
|
76
|
+
Take the answers and write them into `operator/profile/profile.md`, overwriting
|
|
77
|
+
the placeholder. Keep it a short human-readable paragraph plus a bulleted list
|
|
78
|
+
of preferences. Do not invent fields the operator did not name.
|
|
79
|
+
|
|
80
|
+
### 4. aliasOffer
|
|
81
|
+
|
|
82
|
+
The colleague probably has not aliased the PA yet. Offer one line and let them
|
|
83
|
+
decide whether to run it. The exact command is:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
echo "alias <pa-name>='cd <target-dir> && pnpm exec pa session'" >> ~/.zshrc && source ~/.zshrc
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Substitute `<pa-name>` from `operator/config/preferences.json` and
|
|
90
|
+
`<target-dir>` from `process.cwd()`. If the operator says yes, print the exact
|
|
91
|
+
line to run; do not run it for them (their shell rc is not our file to write).
|
|
92
|
+
|
|
93
|
+
### 5. workspaceOffer
|
|
94
|
+
|
|
95
|
+
Ask if they want the local workspace started now. On yes, run
|
|
96
|
+
`pnpm exec pa workspace start` in the background and print the URL from
|
|
97
|
+
`operator/config/preferences.json:workspacePort` (default 4200). On no, note
|
|
98
|
+
that `pnpm exec pa workspace start` is the verb whenever they want it.
|
|
99
|
+
|
|
100
|
+
### 6. atlassianOffer
|
|
101
|
+
|
|
102
|
+
Ask if they use Jira or Confluence. On no, mark skipped. On yes, walk them
|
|
103
|
+
through:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
pnpm exec secrets set JIRA_DOMAIN <site> # for <site>.atlassian.net
|
|
107
|
+
pnpm exec secrets set JIRA_EMAIL <email>
|
|
108
|
+
pnpm exec secrets set JIRA_API_TOKEN <paste-token> # id.atlassian.com/manage-profile/security/api-tokens
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The vault self-initialises on the first `secrets set`. After the three sets,
|
|
112
|
+
run `pnpm exec jira auth` and `pnpm exec confluence auth` and report the
|
|
113
|
+
identity each returns.
|
|
114
|
+
|
|
115
|
+
## Completion
|
|
116
|
+
|
|
117
|
+
When every step is done or skipped, write `completedAt` and tell the operator
|
|
118
|
+
onboarding is done. Give them one line: "You are ready. Ask me anything, or
|
|
119
|
+
say `bring me up to speed` and I will run the standard session-boot briefing."
|
|
@@ -33,7 +33,7 @@ The workspace server hosts `viewer.html` — the PA's canonical surface for long
|
|
|
33
33
|
|
|
34
34
|
## Supervisor-aware lifecycle
|
|
35
35
|
|
|
36
|
-
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com
|
|
36
|
+
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com.<paName>.workspace.plist`) or systemd (`~/.config/systemd/user/<paName>-workspace.service`), `stop` is refused and `restart` delegates to the supervisor. This is the in-place version-pickup path after `pnpm update @stravica-ai/digital-operator-pa`. Unit labels follow the scheduler's `com.{agent}.{id}` convention with `id = workspace` and `agent` resolved from `paName` in `operator/config/preferences.json`; pre-0.1.44 installs are still recognised under the legacy `com.dave.workspace` / `dave-workspace.service` name until you run `install-daemon install --migrate` to rename.
|
|
37
37
|
|
|
38
38
|
## When to use the workspace
|
|
39
39
|
|
|
@@ -27,6 +27,31 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
27
27
|
- `library status [--json]`
|
|
28
28
|
GETs `/status` from the service, plus `git rev-list HEAD..origin/main --count`
|
|
29
29
|
per tier clone.
|
|
30
|
+
- `library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]`
|
|
31
|
+
Scheduled auto-merger for library up-path PRs. Scans configured library
|
|
32
|
+
repos for open PRs from branch `library-sync-up/<device>` and merges each
|
|
33
|
+
one whose diff is confined to the paths `<device>` owns per
|
|
34
|
+
`operator/registry/repos.json`. Anything else is a refusal: the PR is
|
|
35
|
+
left open with a one-time comment (idempotent via an HTML marker) and no
|
|
36
|
+
merge is ever attempted. Writes one JSONL line per PR per pass to
|
|
37
|
+
`library.upMerge.eventsLog` (default `.cache/library-up-merger/events.jsonl`).
|
|
38
|
+
Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
|
|
39
|
+
no ingest touch). Config in the `library.upMerge` stanza; see below.
|
|
40
|
+
|
|
41
|
+
### Scope rule (verbatim)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
A PR is eligible when both:
|
|
45
|
+
(1) its head branch matches "library-sync-up/<device>"
|
|
46
|
+
(2) every changed file's repo-relative path falls under a path <device>
|
|
47
|
+
owns for that library repo per operator/registry/repos.json:
|
|
48
|
+
for each mount M where M.upPath === true and M.upPathOwner === <device>:
|
|
49
|
+
allowed prefix = M.from + include, for each include in M.upPathInclude
|
|
50
|
+
a file matches when its path equals a prefix (single-file include)
|
|
51
|
+
or starts with prefix + "/" (directory include).
|
|
52
|
+
Any file outside the device's allowed prefixes is a refusal - the PR is
|
|
53
|
+
left open, a one-time comment is posted, and no merge is ever attempted.
|
|
54
|
+
```
|
|
30
55
|
|
|
31
56
|
## Configuration
|
|
32
57
|
|
|
@@ -45,6 +70,40 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
45
70
|
|
|
46
71
|
- `LIBRARY_PULL_CLONES_ROOT` env — overrides the pull-clones root at runtime.
|
|
47
72
|
|
|
73
|
+
- `library.upMerge` stanza in preferences (used by `library up-merge` only):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"library": {
|
|
78
|
+
"upMerge": {
|
|
79
|
+
"repos": ["Stravica/stravica-library-wsd"],
|
|
80
|
+
"registryPath": "operator/registry/repos.json",
|
|
81
|
+
"eventsLog": ".cache/library-up-merger/events.jsonl",
|
|
82
|
+
"mergeMethod": "merge",
|
|
83
|
+
"branchPrefix": "library-sync-up/",
|
|
84
|
+
"deleteBranchOnMerge": true,
|
|
85
|
+
"ghBin": "gh"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every field has a safe default; `repos` defaults to `[]` (no repos scanned)
|
|
92
|
+
and can be overridden per invocation with `--repo <owner/name>` (repeatable).
|
|
93
|
+
|
|
94
|
+
### Migrating from the HQ `library-up-merger` tool
|
|
95
|
+
|
|
96
|
+
`library up-merge` is a behaviour-identical promotion of the HQ tool at
|
|
97
|
+
`operator/extensions/tools/library-up-merger/`. The scheduled command
|
|
98
|
+
changes from
|
|
99
|
+
`node operator/extensions/tools/library-up-merger/bin/library-up-merger`
|
|
100
|
+
to `pnpm exec library up-merge`. The scope rule, refusal marker, events
|
|
101
|
+
log path, and default merge method are unchanged; the HQ tool's
|
|
102
|
+
`config.json` maps 1:1 to the `library.upMerge` stanza above (`repos`,
|
|
103
|
+
`registryPath`, `eventsLog`, `mergeMethod`, `branchPrefix`,
|
|
104
|
+
`deleteBranchOnMerge`). Repoint the schedule at the new command and
|
|
105
|
+
retire the HQ tool.
|
|
106
|
+
|
|
48
107
|
Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
|
|
49
108
|
no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
|
|
50
109
|
interim was removed in the same pass.
|
|
@@ -22,6 +22,7 @@ import { runGet } from './verbs/get.mjs';
|
|
|
22
22
|
import { runSearch } from './verbs/search.mjs';
|
|
23
23
|
import { runStatus } from './verbs/status.mjs';
|
|
24
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
25
|
+
import { runUpMerge } from './verbs/up-merge.mjs';
|
|
25
26
|
|
|
26
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
|
|
@@ -52,13 +53,15 @@ Usage:
|
|
|
52
53
|
library search <query> [--files-only]
|
|
53
54
|
library status [--json]
|
|
54
55
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
56
|
+
library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
|
|
55
57
|
library -h | --help
|
|
56
58
|
library -v | --version
|
|
57
59
|
|
|
58
60
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
59
61
|
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
60
62
|
one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
|
|
61
|
-
with a clean not-applicable message.
|
|
63
|
+
with a clean not-applicable message. 'library up-merge' is a GitHub-side
|
|
64
|
+
maintenance verb and runs in both SIMPLE and REMOTE mode.
|
|
62
65
|
|
|
63
66
|
--help and --version never touch config or the vault, so they always work
|
|
64
67
|
from a directory with no operator config and no vault installed.
|
|
@@ -102,6 +105,18 @@ export async function main(argv, seams = {}) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
if (config.mode === 'simple') {
|
|
108
|
+
// `library up-merge` is a GitHub-side maintenance verb: it reads
|
|
109
|
+
// library.upMerge from preferences and hits gh, never the ingest
|
|
110
|
+
// service. It runs the same in SIMPLE and REMOTE mode.
|
|
111
|
+
if (verb === 'up-merge') {
|
|
112
|
+
const ctxUp = { config, log, err };
|
|
113
|
+
try {
|
|
114
|
+
return await runUpMerge({ ...ctxUp, args: parseUpMerge(rest) });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
err(`[library up-merge] ${e.message ?? e}`);
|
|
117
|
+
return typeof e.exitCode === 'number' ? e.exitCode : 2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
105
120
|
// `library status` is the one verb useful in SIMPLE mode too: a plain
|
|
106
121
|
// one-line mode summary that tells the operator the CLI is installed
|
|
107
122
|
// and the remote path is intentionally not wired. No vault touch,
|
|
@@ -141,6 +156,7 @@ export async function main(argv, seams = {}) {
|
|
|
141
156
|
case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
|
|
142
157
|
case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
|
|
143
158
|
case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
|
|
159
|
+
case 'up-merge': return await runUpMerge({ ...ctx, args: parseUpMerge(rest) });
|
|
144
160
|
default:
|
|
145
161
|
err(`[library] unknown verb: ${verb}`);
|
|
146
162
|
log(USAGE);
|
|
@@ -195,6 +211,30 @@ function parseStatus(argv) {
|
|
|
195
211
|
});
|
|
196
212
|
return { json: values.json };
|
|
197
213
|
}
|
|
214
|
+
function parseUpMerge(argv) {
|
|
215
|
+
const { values } = parseArgs({
|
|
216
|
+
args: argv,
|
|
217
|
+
strict: true,
|
|
218
|
+
allowPositionals: false,
|
|
219
|
+
options: {
|
|
220
|
+
repo: { type: 'string', multiple: true },
|
|
221
|
+
pr: { type: 'string' },
|
|
222
|
+
'dry-run': { type: 'boolean', default: false },
|
|
223
|
+
once: { type: 'boolean', default: false },
|
|
224
|
+
verbose: { type: 'boolean', default: false },
|
|
225
|
+
json: { type: 'boolean', default: false },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const pr = values.pr ? parseInt(values.pr, 10) : undefined;
|
|
229
|
+
return {
|
|
230
|
+
repos: values.repo,
|
|
231
|
+
pr,
|
|
232
|
+
dryRun: values['dry-run'],
|
|
233
|
+
once: values.once,
|
|
234
|
+
verbose: values.verbose,
|
|
235
|
+
json: values.json,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
198
238
|
function parseRefreshClones(argv) {
|
|
199
239
|
const { values } = parseArgs({
|
|
200
240
|
args: argv,
|