@stravica-ai/digital-operator-pa 0.1.43 → 0.1.44

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
@@ -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,
@@ -0,0 +1,103 @@
1
+ // Thin wrappers around the gh CLI, factored so tests can inject a runner
2
+ // and never spawn a real gh. Auth is handled by gh from the host's own
3
+ // login (TheFoot on smarthome01 for the scheduled path today).
4
+ //
5
+ // The runner signature is deliberately narrow: (cmd, args, { input }) ->
6
+ // Promise<{ code, stdout, stderr }>. Every wrapper takes an optional
7
+ // `runner` seam that defaults to the module-local `defaultRunner`, which
8
+ // is a plain spawn. Tests build a runner that inspects args and returns
9
+ // canned stdout/stderr, so no gh process is ever invoked.
10
+ //
11
+ // Ported from operator/extensions/tools/library-up-merger/src/github.mjs
12
+ // and factored for injection (w-2026-09-16-dave-014, d-2026-09-17-013).
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ /**
17
+ * @typedef {(cmd: string, args: string[], opts?: { input?: string }) => Promise<{ code: number, stdout: string, stderr: string }>} Runner
18
+ */
19
+
20
+ /** @type {Runner} */
21
+ export function defaultRunner(cmd, args, { input } = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
24
+ let stdout = '';
25
+ let stderr = '';
26
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
27
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
28
+ child.on('error', reject);
29
+ child.on('close', (code) => {
30
+ resolve({ code, stdout, stderr });
31
+ });
32
+ if (input != null) child.stdin.end(input);
33
+ else child.stdin.end();
34
+ });
35
+ }
36
+
37
+ async function ghJson(args, { runner = defaultRunner, ghBin = 'gh' } = {}) {
38
+ const res = await runner(ghBin, args);
39
+ if (res.code !== 0) {
40
+ const err = new Error(`gh ${args.join(' ')} exited ${res.code}: ${res.stderr.trim()}`);
41
+ err.stderr = res.stderr;
42
+ err.stdout = res.stdout;
43
+ throw err;
44
+ }
45
+ const text = res.stdout.trim();
46
+ if (!text) return null;
47
+ return JSON.parse(text);
48
+ }
49
+
50
+ export async function listOpenPrs(repoSlug, opts = {}) {
51
+ const data = await ghJson([
52
+ 'pr', 'list', '--repo', repoSlug, '--state', 'open',
53
+ '--json', 'number,headRefName,author,title,url,isDraft',
54
+ '--limit', '100',
55
+ ], opts);
56
+ return data || [];
57
+ }
58
+
59
+ export async function listChangedFiles(repoSlug, number, opts = {}) {
60
+ const data = await ghJson([
61
+ 'pr', 'view', String(number), '--repo', repoSlug,
62
+ '--json', 'files',
63
+ ], opts);
64
+ return (data && Array.isArray(data.files) ? data.files : []).map((f) => f.path);
65
+ }
66
+
67
+ export async function mergePr(repoSlug, number, {
68
+ method = 'merge', deleteBranch = true, runner = defaultRunner, ghBin = 'gh',
69
+ } = {}) {
70
+ const args = ['pr', 'merge', String(number), '--repo', repoSlug, `--${method}`];
71
+ if (deleteBranch) args.push('--delete-branch');
72
+ const res = await runner(ghBin, args);
73
+ return {
74
+ ok: res.code === 0,
75
+ code: res.code,
76
+ stdout: res.stdout.trim(),
77
+ stderr: res.stderr.trim(),
78
+ };
79
+ }
80
+
81
+ export async function commentOnPr(repoSlug, number, body, {
82
+ runner = defaultRunner, ghBin = 'gh',
83
+ } = {}) {
84
+ const res = await runner(
85
+ ghBin,
86
+ ['pr', 'comment', String(number), '--repo', repoSlug, '--body-file', '-'],
87
+ { input: body },
88
+ );
89
+ return {
90
+ ok: res.code === 0,
91
+ code: res.code,
92
+ stdout: res.stdout.trim(),
93
+ stderr: res.stderr.trim(),
94
+ };
95
+ }
96
+
97
+ export async function listExistingComments(repoSlug, number, opts = {}) {
98
+ const data = await ghJson(
99
+ ['pr', 'view', String(number), '--repo', repoSlug, '--json', 'comments'],
100
+ opts,
101
+ );
102
+ return (data && Array.isArray(data.comments) ? data.comments : []).map((c) => c.body || '');
103
+ }
@@ -0,0 +1,95 @@
1
+ // Scope-rule computation for library up-path PRs.
2
+ //
3
+ // A PR from head branch `<branchPrefix><device>` on library repo <repoSlug> may be merged
4
+ // when and only when EVERY changed file's repo-relative path falls under one of the
5
+ // device's owned up-paths for that library repo, computed from operator/registry/repos.json:
6
+ //
7
+ // For each mount M in repos[<library repo id>].mounts where M.upPath === true
8
+ // and M.upPathOwner === <device>:
9
+ // allowedPrefix = M.from + include, for each include in (M.upPathInclude || [])
10
+ // A file matches when its path === allowedPrefix (single-file include)
11
+ // or starts with allowedPrefix + "/" (directory include).
12
+ //
13
+ // A device with no owned up-paths on the repo, or a file matching none of its allowed
14
+ // prefixes, is a refusal. Refusals never merge.
15
+ //
16
+ // Ported verbatim (behaviour) from operator/extensions/tools/library-up-merger/src/scope.mjs
17
+ // on the promotion to core (w-2026-09-16-dave-014, d-2026-09-17-013). The HQ tool was the
18
+ // reference implementation; this module is the canonical home from 0.1.44 onward.
19
+
20
+ /**
21
+ * @param {object} registry parsed operator/registry/repos.json
22
+ * @param {string} repoSlug e.g. "Stravica/stravica-library-wsd"
23
+ * @returns {string|null} matching registry key, or null
24
+ */
25
+ export function repoIdForSlug(registry, repoSlug) {
26
+ const wanted = repoSlug.toLowerCase();
27
+ for (const [id, cfg] of Object.entries(registry.repos || {})) {
28
+ const remote = (cfg.gitRemote || '').toLowerCase();
29
+ // git@github.com:Owner/name.git -> Owner/name
30
+ const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
31
+ if (m && m[1] === wanted) return id;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Compute the sorted, de-duplicated list of allowed repo-relative path prefixes for a
38
+ * device on a library repo. Each entry is either an exact file path or a directory path
39
+ * (no trailing slash) - the matcher below appends "/" for directory matching.
40
+ */
41
+ export function allowedPathsForDevice(registry, repoId, device) {
42
+ const repo = (registry.repos || {})[repoId];
43
+ if (!repo || !Array.isArray(repo.mounts)) return [];
44
+ const out = new Set();
45
+ for (const mount of repo.mounts) {
46
+ if (!mount || mount.upPath !== true) continue;
47
+ if (mount.upPathOwner !== device) continue;
48
+ const fromPrefix = String(mount.from || '').replace(/\/+$/, '');
49
+ const includes = Array.isArray(mount.upPathInclude) ? mount.upPathInclude : [];
50
+ for (const inc of includes) {
51
+ const clean = String(inc).replace(/\/+$/, '');
52
+ if (!clean) continue;
53
+ out.add(fromPrefix ? `${fromPrefix}/${clean}` : clean);
54
+ }
55
+ }
56
+ return [...out].sort();
57
+ }
58
+
59
+ /**
60
+ * Does `filePath` (repo-relative) fall under one of the `allowed` entries?
61
+ * An entry matches when the path equals it, or starts with `entry + "/"`.
62
+ */
63
+ export function fileAllowed(filePath, allowed) {
64
+ const f = String(filePath || '').replace(/^\/+/, '');
65
+ for (const entry of allowed) {
66
+ if (f === entry) return true;
67
+ if (f.startsWith(`${entry}/`)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Classify a PR's file list against a device's allowed prefixes.
74
+ * @returns {{ ok: boolean, refused: string[], allowed: string[] }}
75
+ */
76
+ export function classifyFiles(files, allowed) {
77
+ const refused = [];
78
+ const allowedFiles = [];
79
+ for (const f of files) {
80
+ if (fileAllowed(f, allowed)) allowedFiles.push(f);
81
+ else refused.push(f);
82
+ }
83
+ return { ok: refused.length === 0, refused, allowed: allowedFiles };
84
+ }
85
+
86
+ /**
87
+ * Parse a device out of a head-ref of the form `<branchPrefix><device>`.
88
+ * Returns null if the branch does not match.
89
+ */
90
+ export function deviceFromBranch(branch, branchPrefix) {
91
+ if (!branch || !branch.startsWith(branchPrefix)) return null;
92
+ const rest = branch.slice(branchPrefix.length);
93
+ if (!rest || rest.includes('/')) return null;
94
+ return rest;
95
+ }
@@ -0,0 +1,290 @@
1
+ // verbs/up-merge.mjs — `library up-merge`
2
+ //
3
+ // Scheduled auto-merger for library up-path PRs.
4
+ //
5
+ // Scans configured library repos for open PRs from branch
6
+ // `library-sync-up/<device>`, and merges each one whose diff is confined
7
+ // to the paths <device> owns per operator/registry/repos.json. Anything
8
+ // else is a refusal, never a merge, and gets a one-time comment carrying
9
+ // the REFUSAL_MARKER (idempotent).
10
+ //
11
+ // Promoted from operator/extensions/tools/library-up-merger/src/cli.mjs
12
+ // (HQ tool, w-2026-09-16-dave-014, d-2026-09-17-013). Config now lives
13
+ // in the merged operator preferences under `library.upMerge`; defaults
14
+ // mirror the HQ tool's config.json.
15
+ //
16
+ // Mode note: `up-merge` is a GitHub-side maintenance verb and runs the
17
+ // same way regardless of `library.mode`. It reads `library.upMerge`
18
+ // from the merged preferences and does not touch the remote-library
19
+ // ingest surface. The main cli.mjs short-circuits the SIMPLE-mode
20
+ // refusal for this verb.
21
+
22
+ import { readFile, mkdir, appendFile } from 'node:fs/promises';
23
+ import { dirname, resolve } from 'node:path';
24
+
25
+ import {
26
+ repoIdForSlug,
27
+ allowedPathsForDevice,
28
+ classifyFiles,
29
+ deviceFromBranch,
30
+ } from '../up-merge/scope.mjs';
31
+ import {
32
+ listOpenPrs,
33
+ listChangedFiles,
34
+ mergePr,
35
+ commentOnPr,
36
+ listExistingComments,
37
+ defaultRunner,
38
+ } from '../up-merge/github.mjs';
39
+
40
+ /**
41
+ * Default `library.upMerge` stanza. Mirrors the HQ tool's config.json so
42
+ * the estate keeps its current behaviour with no preferences change.
43
+ */
44
+ export const DEFAULT_UP_MERGE_CONFIG = Object.freeze({
45
+ repos: [],
46
+ registryPath: 'operator/registry/repos.json',
47
+ eventsLog: '.cache/library-up-merger/events.jsonl',
48
+ mergeMethod: 'merge',
49
+ branchPrefix: 'library-sync-up/',
50
+ deleteBranchOnMerge: true,
51
+ ghBin: 'gh',
52
+ });
53
+
54
+ const REFUSAL_MARKER = '<!-- library-up-merger:refusal -->';
55
+
56
+ /**
57
+ * Resolve the `library.upMerge` stanza from merged preferences, layering
58
+ * on top of {@link DEFAULT_UP_MERGE_CONFIG}. Missing block → all defaults.
59
+ *
60
+ * @param {object} preferences merged preferences object
61
+ * @returns {typeof DEFAULT_UP_MERGE_CONFIG}
62
+ */
63
+ export function resolveUpMergeConfig(preferences) {
64
+ const raw = preferences && typeof preferences === 'object'
65
+ ? preferences.library && preferences.library.upMerge
66
+ : null;
67
+ const block = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
68
+ return {
69
+ ...DEFAULT_UP_MERGE_CONFIG,
70
+ ...block,
71
+ repos: Array.isArray(block.repos) ? block.repos : DEFAULT_UP_MERGE_CONFIG.repos,
72
+ };
73
+ }
74
+
75
+ function refusalBody(device, allowed, refused) {
76
+ const allowedList = allowed.length
77
+ ? allowed.map((p) => ` - ${p}`).join('\n')
78
+ : ' (none - device has no owned up-paths on this repo)';
79
+ const refusedList = refused.map((p) => ` - ${p}`).join('\n');
80
+ return [
81
+ REFUSAL_MARKER,
82
+ 'library up-merge refuses to merge this PR: it touches paths outside the up-path scope for the device on its head branch.',
83
+ '',
84
+ `Device (from branch): \`${device}\``,
85
+ '',
86
+ 'Allowed prefixes for this device on this repo (from operator/registry/repos.json):',
87
+ allowedList,
88
+ '',
89
+ 'Refused files:',
90
+ refusedList,
91
+ '',
92
+ 'Split the out-of-scope files into a separate PR owned by whichever device owns them, or narrow this PR to the allowed prefixes.',
93
+ ].join('\n');
94
+ }
95
+
96
+ async function appendEvent(logPath, event) {
97
+ await mkdir(dirname(logPath), { recursive: true });
98
+ await appendFile(logPath, `${JSON.stringify(event)}\n`);
99
+ }
100
+
101
+ async function loadJson(path) {
102
+ const raw = await readFile(path, 'utf8');
103
+ return JSON.parse(raw);
104
+ }
105
+
106
+ /**
107
+ * @param {object} ctx
108
+ * @param {object} ctx.config loaded library config (carries repoRoot + preferences)
109
+ * @param {object} [ctx.args] { repos?: string[], pr?: number, dryRun?: boolean, once?: boolean, verbose?: boolean, json?: boolean }
110
+ * @param {(...m:unknown[])=>void} [ctx.log]
111
+ * @param {(...m:unknown[])=>void} [ctx.err]
112
+ * @param {import('../up-merge/github.mjs').Runner} [ctx.ghRunner] gh spawn seam (tests inject)
113
+ * @returns {Promise<number>} exit code (0 ok, 2 errors)
114
+ */
115
+ export async function runUpMerge({
116
+ config,
117
+ args = {},
118
+ log = defaultLog,
119
+ err = defaultErr,
120
+ ghRunner = defaultRunner,
121
+ }) {
122
+ const upMerge = resolveUpMergeConfig(config.preferences);
123
+ const estateRoot = config.repoRoot;
124
+
125
+ const registryPath = resolve(estateRoot, upMerge.registryPath);
126
+ let registry;
127
+ try {
128
+ registry = await loadJson(registryPath);
129
+ } catch (e) {
130
+ err(`[library up-merge] failed to read registry ${registryPath}: ${e.message ?? e}`);
131
+ return 2;
132
+ }
133
+
134
+ const repos = args.repos && args.repos.length ? args.repos : upMerge.repos;
135
+ if (!repos.length) {
136
+ err('[library up-merge] no repos configured; set library.upMerge.repos in preferences or pass --repo');
137
+ return 1;
138
+ }
139
+ if (args.pr && (!args.repos || args.repos.length === 0)) {
140
+ err('[library up-merge] --pr requires --repo');
141
+ return 1;
142
+ }
143
+
144
+ const eventsLog = resolve(estateRoot, upMerge.eventsLog);
145
+ const runAt = new Date().toISOString();
146
+ const ghOpts = { runner: ghRunner, ghBin: upMerge.ghBin };
147
+
148
+ const summary = {
149
+ runAt,
150
+ dryRun: !!args.dryRun,
151
+ repos: [],
152
+ merged: 0,
153
+ refused: 0,
154
+ skipped: 0,
155
+ errors: 0,
156
+ };
157
+
158
+ for (const repoSlug of repos) {
159
+ const repoId = repoIdForSlug(registry, repoSlug);
160
+ if (!repoId) {
161
+ const line = { runAt, repo: repoSlug, verdict: 'error', reason: 'repo-not-in-registry' };
162
+ await appendEvent(eventsLog, line);
163
+ log(`error ${repoSlug}: not in registry`);
164
+ summary.errors += 1;
165
+ continue;
166
+ }
167
+
168
+ let prs;
169
+ try {
170
+ prs = await listOpenPrs(repoSlug, ghOpts);
171
+ } catch (e) {
172
+ const line = {
173
+ runAt, repo: repoSlug, verdict: 'error',
174
+ reason: 'list-prs-failed', detail: String(e.message ?? e),
175
+ };
176
+ await appendEvent(eventsLog, line);
177
+ log(`error ${repoSlug}: list PRs failed - ${e.message ?? e}`);
178
+ summary.errors += 1;
179
+ continue;
180
+ }
181
+
182
+ const repoStats = { repo: repoSlug, considered: prs.length, merged: 0, refused: 0, skipped: 0 };
183
+
184
+ for (const pr of prs) {
185
+ if (args.pr && pr.number !== args.pr) continue;
186
+ const device = deviceFromBranch(pr.headRefName, upMerge.branchPrefix);
187
+ if (!device) {
188
+ if (args.verbose) log(`skip ${repoSlug}#${pr.number}: head ${pr.headRefName} not an up-path branch`);
189
+ repoStats.skipped += 1;
190
+ summary.skipped += 1;
191
+ continue;
192
+ }
193
+ if (pr.isDraft) {
194
+ if (args.verbose) log(`skip ${repoSlug}#${pr.number}: draft`);
195
+ repoStats.skipped += 1;
196
+ summary.skipped += 1;
197
+ continue;
198
+ }
199
+
200
+ const allowed = allowedPathsForDevice(registry, repoId, device);
201
+ let files;
202
+ try {
203
+ files = await listChangedFiles(repoSlug, pr.number, ghOpts);
204
+ } catch (e) {
205
+ await appendEvent(eventsLog, {
206
+ runAt, repo: repoSlug, pr: pr.number, device, verdict: 'error',
207
+ reason: 'list-files-failed', detail: String(e.message ?? e),
208
+ });
209
+ log(`error ${repoSlug}#${pr.number}: list files failed - ${e.message ?? e}`);
210
+ summary.errors += 1;
211
+ continue;
212
+ }
213
+ const cls = classifyFiles(files, allowed);
214
+
215
+ if (!cls.ok) {
216
+ let commented = false;
217
+ if (!args.dryRun) {
218
+ try {
219
+ const existing = await listExistingComments(repoSlug, pr.number, ghOpts);
220
+ const alreadyComplained = existing.some((body) => (body || '').includes(REFUSAL_MARKER));
221
+ if (!alreadyComplained) {
222
+ const res = await commentOnPr(
223
+ repoSlug, pr.number, refusalBody(device, allowed, cls.refused), ghOpts,
224
+ );
225
+ commented = res.ok;
226
+ }
227
+ } catch (e) {
228
+ err(`comment ${repoSlug}#${pr.number} failed: ${e.message ?? e}`);
229
+ }
230
+ }
231
+ await appendEvent(eventsLog, {
232
+ runAt, repo: repoSlug, pr: pr.number, device, verdict: 'refused',
233
+ refusedFiles: cls.refused, allowedFiles: cls.allowed, allowedPrefixes: allowed,
234
+ commented, dryRun: !!args.dryRun,
235
+ });
236
+ log(`refused ${repoSlug}#${pr.number} (${device}): ${cls.refused.length} out-of-scope file(s) - ${cls.refused.slice(0, 3).join(', ')}${cls.refused.length > 3 ? ', ...' : ''}`);
237
+ repoStats.refused += 1;
238
+ summary.refused += 1;
239
+ continue;
240
+ }
241
+
242
+ if (args.dryRun) {
243
+ await appendEvent(eventsLog, {
244
+ runAt, repo: repoSlug, pr: pr.number, device, verdict: 'would-merge',
245
+ files, allowedPrefixes: allowed, dryRun: true,
246
+ });
247
+ log(`would-merge ${repoSlug}#${pr.number} (${device}): ${files.length} file(s) in scope`);
248
+ repoStats.merged += 1;
249
+ summary.merged += 1;
250
+ continue;
251
+ }
252
+
253
+ const mergeRes = await mergePr(repoSlug, pr.number, {
254
+ method: upMerge.mergeMethod,
255
+ deleteBranch: upMerge.deleteBranchOnMerge !== false,
256
+ ...ghOpts,
257
+ });
258
+ if (mergeRes.ok) {
259
+ await appendEvent(eventsLog, {
260
+ runAt, repo: repoSlug, pr: pr.number, device, verdict: 'merged',
261
+ files, allowedPrefixes: allowed,
262
+ });
263
+ log(`merged ${repoSlug}#${pr.number} (${device}): ${files.length} file(s) in scope`);
264
+ repoStats.merged += 1;
265
+ summary.merged += 1;
266
+ } else {
267
+ await appendEvent(eventsLog, {
268
+ runAt, repo: repoSlug, pr: pr.number, device, verdict: 'merge-failed',
269
+ exitCode: mergeRes.code, stderr: mergeRes.stderr, files,
270
+ });
271
+ log(`merge-failed ${repoSlug}#${pr.number} (${device}): exit ${mergeRes.code} - ${(mergeRes.stderr || '').split('\n')[0]}`);
272
+ summary.errors += 1;
273
+ }
274
+ }
275
+
276
+ summary.repos.push(repoStats);
277
+ }
278
+
279
+ if (args.json) {
280
+ log(JSON.stringify(summary, null, 2));
281
+ } else {
282
+ log(`summary ${JSON.stringify(summary)}`);
283
+ }
284
+ return 0;
285
+ }
286
+
287
+ function defaultLog(...m) { process.stdout.write(m.join(' ') + '\n'); }
288
+ function defaultErr(...m) { process.stderr.write(m.join(' ') + '\n'); }
289
+
290
+ export { REFUSAL_MARKER };