@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.
@@ -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.dave.workspace.plist)
364
- or systemd (~/.config/systemd/user/dave-workspace.service), \`stop\` is refused and \`restart\`
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 runSync({
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.dave.workspace.plist`) or systemd (`~/.config/systemd/user/dave-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`.
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,