omp-conductor 0.17.1 → 0.18.1

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.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
package/src/graph.ts CHANGED
@@ -14,9 +14,10 @@
14
14
  * - **This package never builds or mutates an index, and never depends on the
15
15
  * indexer for dispatch.** The optional health surface runs the indexer's
16
16
  * read-only `list_projects` query; nothing spawns the graph server or imports
17
- * it. `graph-setup` prints commands, and with `--write` writes two systemd
18
- * units — it does not enable them, because a package that silently writes
19
- * root-level state is not one you can trust with a fleet.
17
+ * it. `setup graph` renders a script and two systemd units per project and
18
+ * stages them only after its consent prompt — it does not enable them,
19
+ * because a package that silently writes root-level state is not one you can
20
+ * trust with a fleet.
20
21
  * - **A worker never queries its own worktree.** An index is keyed by the
21
22
  * realpath of the directory it was built from, with no git-worktree awareness,
22
23
  * so a run's `worktrees/<issue>` path is always an empty project. Workers are
@@ -28,7 +29,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
28
29
  import { homedir, userInfo } from "node:os";
29
30
  import { dirname, join } from "node:path";
30
31
  import { expandHome, stateDir } from "./config.ts";
31
- import type { ProjectConfig, RepoTarget } from "./types.ts";
32
+ import type { GraphToolsObservation, ProjectConfig, RepoTarget } from "./types.ts";
32
33
 
33
34
  /**
34
35
  * The indexer's own CLI, invoked by name rather than by path so the generated
@@ -37,12 +38,200 @@ import type { ProjectConfig, RepoTarget } from "./types.ts";
37
38
  */
38
39
  const INDEXER = "codebase-memory-mcp";
39
40
 
40
- /** Both units and the script share this stem; `cbm` is the indexer's own prefix. */
41
+ /**
42
+ * The MCP server name the graph tools arrive under in a session's registry,
43
+ * in the spellings the harness prefixes tools with (#726). `graphToolsPresent`
44
+ * matches against these, so the runtime observation and `resolvePrereqs`'s
45
+ * config check share one identity and cannot drift apart. The underscore form
46
+ * is the harness's normalised spelling for a server name; the verbatim form
47
+ * covers servers that keep their hyphens.
48
+ */
49
+ export const GRAPH_MCP_SERVER_NAMES: readonly string[] = [INDEXER, INDEXER.replace(/-/g, "_")];
50
+
51
+ /**
52
+ * Whether the code-graph tools are in a live session's registry (#726).
53
+ *
54
+ * Takes the session's own enabled tool names and matches the harness's MCP
55
+ * mounting convention (`mcp__<server>_<tool>`). This is the runtime
56
+ * observation: unlike {@link resolvePrereqs}'s `mounted` (which reads
57
+ * `mcp.json` and can only say what a session should mount), it answers what
58
+ * the session actually had, so "the model ignored a tool it had" and "the
59
+ * tool was missing" stop looking identical from outside the session.
60
+ */
61
+ export function graphToolsPresent(toolNames: readonly string[]): boolean {
62
+ return toolNames.some(
63
+ (name) =>
64
+ name.startsWith("mcp__") &&
65
+ GRAPH_MCP_SERVER_NAMES.some((server) => name.startsWith(`mcp__${server}_`)),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Poll a session's registry for the code-graph observation (#726).
71
+ *
72
+ * Measured against the real harness rather than assumed: MCP wiring finalises
73
+ * after `createAgentSession` resolves (a deferred discovery pass), so the
74
+ * registry read can throw in the window right after session creation, and the
75
+ * graph tools surface under the *enabled* names — the active names stop at
76
+ * the core tools. This therefore polls the enabled surface until the read
77
+ * stops throwing, bounded by `deadlineMs`; a surface that never becomes
78
+ * readable (or a build that does not expose one — `getEnabledToolNames` is
79
+ * `undefined`) records *no* observation — never "graph tools absent", which
80
+ * is the `present: false` truth value of a surface that was read.
81
+ *
82
+ * @param getEnabledToolNames the session's `getEnabledToolNames` read, already
83
+ * bound to its session (the SDK method dereferences private state), or
84
+ * `undefined` when the harness surface is absent
85
+ */
86
+ export async function observeGraphTools(
87
+ getEnabledToolNames: (() => string[]) | undefined,
88
+ options: { intervalMs?: number; deadlineMs?: number } = {},
89
+ ): Promise<GraphToolsObservation | undefined> {
90
+ if (getEnabledToolNames === undefined) return undefined;
91
+ const intervalMs = options.intervalMs ?? 250;
92
+ const deadlineMs = options.deadlineMs ?? 15_000;
93
+ const start = Date.now();
94
+ for (;;) {
95
+ try {
96
+ return { present: graphToolsPresent(getEnabledToolNames()), at: Date.now() };
97
+ } catch {
98
+ if (Date.now() - start >= deadlineMs) return undefined;
99
+ const { promise, resolve } = Promise.withResolvers<void>();
100
+ setTimeout(resolve, intervalMs);
101
+ await promise;
102
+ }
103
+ }
104
+ }
105
+
106
+ /** The shared prefix of every reindex artefact; `cbm` is the indexer's own prefix. */
41
107
  export const REINDEX_UNIT = "cbm-reindex";
42
108
 
43
109
  /** Where a system timer has to live to be enabled by `systemctl`. */
44
110
  export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
45
111
 
112
+ /**
113
+ * The filename-safe stem one project's reindex artefacts share:
114
+ * `cbm-reindex-<slug>`. The slug is the project name folded to `[a-z0-9-]` —
115
+ * the alphabet systemd unit names tolerate — with a deterministic hash
116
+ * fallback for a name that folds to nothing, so every configured project gets
117
+ * its own script, service and timer on the same host and one project's
118
+ * `setup graph` can never replace another's files. The #720 incident was
119
+ * exactly one project-less set of names, silently overwritten by the second
120
+ * project's run.
121
+ *
122
+ * Two distinct names can fold to one stem ("My Project" vs "my_project"), so
123
+ * the write path checks each target's generated-for marker before replacing
124
+ * it rather than trusting the stem alone.
125
+ */
126
+ export function reindexUnitName(p: ProjectConfig): string {
127
+ const name = p.name;
128
+ const slug = name
129
+ .normalize("NFKD")
130
+ .replace(/\p{Diacritic}/gu, "")
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, "-")
133
+ .replace(/^-+|-+$/g, "");
134
+ if (slug !== "") return `${REINDEX_UNIT}-${slug}`;
135
+ let hash = 2166136261;
136
+ for (const byte of new TextEncoder().encode(name)) {
137
+ hash ^= byte;
138
+ hash = Math.imul(hash, 16777619) >>> 0;
139
+ }
140
+ return `${REINDEX_UNIT}-project-${hash.toString(16).padStart(8, "0")}`;
141
+ }
142
+
143
+ /**
144
+ * Where this project's generated refresh script lands: conductor state, not a
145
+ * unit directory, because it is ours to regenerate and needs no root to write.
146
+ */
147
+ export function reindexScriptPath(p: ProjectConfig): string {
148
+ return join(stateDir(), `${reindexUnitName(p)}.sh`);
149
+ }
150
+
151
+ /** Both unit files, from the stem `systemctl enable` will be given. */
152
+ export function unitPaths(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): { service: string; timer: string } {
153
+ const stem = reindexUnitName(p);
154
+ return {
155
+ service: join(unitDir, `${stem}.service`),
156
+ timer: join(unitDir, `${stem}.timer`),
157
+ };
158
+ }
159
+
160
+ /** The `project "<name>"` marker every generated file carries: the script's
161
+ * first header line, and both units' Description. */
162
+ const GENERATED_FOR = /omp-conductor project "([^"]+)"/;
163
+
164
+ /**
165
+ * The project a generated file at `path` was rendered for, or `undefined`
166
+ * when the file is absent, unreadable, or does not carry our marker. Any of
167
+ * the three artefacts can identify its stem's owner, which is what lets a
168
+ * write refuse to replace another project's files instead of silently doing
169
+ * it (#720).
170
+ */
171
+ export function generatedFor(path: string): string | undefined {
172
+ try {
173
+ return GENERATED_FOR.exec(readFileSync(path, "utf8"))?.[1];
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The first of `paths` that already exists and is not this project's own
181
+ * artefact — another project's generated file, or something `setup graph`
182
+ * never wrote — or `undefined` when every path is clear. The stem derives
183
+ * from the project name, and two names can fold to one stem, so an existing
184
+ * file's marker is checked before it is ever overwritten (#720).
185
+ */
186
+ export function stagingConflict(
187
+ p: ProjectConfig,
188
+ paths: readonly string[],
189
+ ): { path: string; owner: string | undefined } | undefined {
190
+ for (const path of paths) {
191
+ if (!existsSync(path)) continue;
192
+ const owner = generatedFor(path);
193
+ if (owner !== p.name) return { path, owner };
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ /** The refusal reason for a conflicting artefact, shared by the pre-consent
199
+ * check and the write backstop so both say the same thing. */
200
+ export function graphConflictMessage(conflict: { path: string; owner: string | undefined }): string {
201
+ return (
202
+ `${conflict.path} already exists and was ` +
203
+ (conflict.owner === undefined
204
+ ? "not generated by `setup graph` — refusing to overwrite it. Delete the file and re-run."
205
+ : `generated for project "${conflict.owner}" — refusing to overwrite another project's reindex artefacts. Delete it and re-run.`)
206
+ );
207
+ }
208
+
209
+ /**
210
+ * The pre-#720 project-less artefact paths that still exist, in the order a
211
+ * host that ran the old version has them. `setup graph` no longer writes or
212
+ * installs these, so a host that upgrades keeps them — and their timer keeps
213
+ * refreshing whichever project generated it last, forever.
214
+ */
215
+ export function legacyReindexFiles(): string[] {
216
+ return [
217
+ join(stateDir(), `${REINDEX_UNIT}.sh`),
218
+ join(stateDir(), `${REINDEX_UNIT}.service`),
219
+ join(stateDir(), `${REINDEX_UNIT}.timer`),
220
+ ].filter((f) => existsSync(f));
221
+ }
222
+
223
+ /** What to do with the legacy files, shared by the plan and the install
224
+ * warning so both say the same thing. */
225
+ export function legacyReindexNote(files: readonly string[]): string {
226
+ return [
227
+ `Legacy project-less reindex files still exist: ${files.join(", ")}.`,
228
+ "`setup graph` no longer writes or installs them, so their timer refreshes",
229
+ "whichever project generated it last, forever. Disable and delete them:",
230
+ " sudo systemctl disable --now cbm-reindex.timer",
231
+ ` rm ${files.join(" ")}`,
232
+ ].join("\n");
233
+ }
234
+
46
235
  /** Where the upstream indexer lives, for an operator who has to go install it. */
47
236
  const INDEXER_SOURCE = "https://github.com/DeusData/codebase-memory-mcp";
48
237
 
@@ -122,35 +311,111 @@ export function graphRepos(p: ProjectConfig): GraphRepo[] {
122
311
  }
123
312
 
124
313
  /**
125
- * The paragraph a worker's brief carries about its repo's graph, or `""` when
126
- * the repo has none — in which case the rendered brief is byte-for-byte the one
127
- * this package shipped before graphs existed.
314
+ * The project key the indexer derives from a path — the `project` argument
315
+ * every code-graph tool takes, and the `<name>` in `<name>.db` under the
316
+ * indexer's store. This is the mirror of the indexer's own
317
+ * `cbm_project_name_from_path` (upstream `src/pipeline/fqn.c`):
318
+ * validator-safe bytes survive, every other byte maps to `-` (non-ASCII
319
+ * bytes to their two lowercase hex digits), runs of `-` and `.` collapse,
320
+ * leading `-`/`.` and trailing `-` trim, an all-separator path falls back to
321
+ * `root`, and a name over 200 bytes is bound with an FNV-1a suffix.
322
+ *
323
+ * A brief that named a wrong key would send a worker to a tool that answers
324
+ * empty and reads as "no graph" — indistinguishable from silence. So this
325
+ * matches the indexer byte for byte, and graph.test.ts pins it against names
326
+ * taken from the live indexer store rather than against this function's own
327
+ * output.
328
+ */
329
+ export function graphProjectKey(graphProject: string): string {
330
+ const bytes = new TextEncoder().encode(graphProject);
331
+ let mapped = "";
332
+ for (const b of bytes) {
333
+ const safe =
334
+ (b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39) || b === 0x2e || b === 0x5f || b === 0x2d;
335
+ if (safe) mapped += String.fromCharCode(b);
336
+ else if (b >= 0x80) mapped += ((b >> 4) & 0xf).toString(16) + (b & 0xf).toString(16);
337
+ else mapped += "-";
338
+ }
339
+
340
+ // Collapse consecutive dashes and dots (the validator also rejects "..").
341
+ let collapsed = "";
342
+ for (const ch of mapped) {
343
+ const prev = collapsed[collapsed.length - 1];
344
+ if ((ch === "-" && prev === "-") || (ch === "." && prev === ".")) continue;
345
+ collapsed += ch;
346
+ }
347
+
348
+ // Trim leading dashes and dots (the validator rejects a leading dot) and
349
+ // trailing dashes. A path that maps to nothing but separators is "root".
350
+ let start = 0;
351
+ while (start < collapsed.length && (collapsed[start] === "-" || collapsed[start] === ".")) start++;
352
+ let end = collapsed.length;
353
+ while (end > start && collapsed[end - 1] === "-") end--;
354
+ const key = collapsed.slice(start, end);
355
+ if (key === "") return "root";
356
+
357
+ // Bound long names the way the indexer does (#624): first 191 bytes plus an
358
+ // 8-hex FNV-1a of the full name, so two long paths that share a prefix but
359
+ // differ later still map to distinct names. The mapped key is pure ASCII, so
360
+ // per-character iteration is per-byte iteration.
361
+ if (key.length > 200) {
362
+ let hash = 2166136261;
363
+ for (const ch of key) {
364
+ hash ^= ch.charCodeAt(0);
365
+ hash = Math.imul(hash, 16777619) >>> 0;
366
+ }
367
+ return key.slice(0, 191) + "-" + hash.toString(16).padStart(8, "0");
368
+ }
369
+ return key;
370
+ }
371
+
372
+ /**
373
+ * The paragraph a worker's brief carries about its repo's graph: the exact
374
+ * `project` key for a configured repo, or an explicit "no graph" statement for
375
+ * an unconfigured one — never silence, because a worker that knows there is no
376
+ * graph stops looking for one.
128
377
  *
129
378
  * The leading newline and the three-space indent are load-bearing: the
130
379
  * placeholder sits immediately before the next numbered item in
131
- * `briefs/worker.md`, so an empty value leaves no blank line behind and a
132
- * non-empty one reads as a continuation of the item above it.
380
+ * `briefs/worker.md`, so the hint reads as a continuation of the item above it
381
+ * and never leaves a blank line behind.
133
382
  *
134
383
  * Every sentence here is defending against one specific failure. A worker that
135
- * passes its own cwd gets an empty answer and concludes there is no graph. A
136
- * worker that trusts the graph as current edits against a snapshot that predates
137
- * its own branch. Both end the same way — a confident diff in the wrong place —
138
- * so the wording says the quiet part out loud rather than describing the tool.
384
+ * derives the project name from its cwd gets an empty answer and concludes
385
+ * there is no graph. A worker that indexes its own throwaway worktree writes a
386
+ * dead index nobody reads — the 9.3 GB of stale worktree-keyed indexes this
387
+ * fleet pruned. A worker that trusts the graph as current edits against a
388
+ * snapshot that predates its own branch. All three end the same way — a
389
+ * confident diff in the wrong place — so the wording says the quiet part out
390
+ * loud rather than describing the tool.
139
391
  */
140
392
  export function graphHint(repo: RepoTarget): string {
141
393
  const path = repo.graphProject;
142
- if (path === undefined) return "";
394
+ if (path === undefined) {
395
+ return (
396
+ "\n" +
397
+ " **This repo has no code graph configured.** Do not go looking for one:\n" +
398
+ " no index exists for it, so the graph tools answer empty whatever you\n" +
399
+ " pass — and indexing your own worktree would build a dead index nobody\n" +
400
+ " reads, not a shortcut. Grep is the tool for this run.\n"
401
+ );
402
+ }
143
403
 
144
404
  return (
145
405
  "\n" +
146
406
  " **This repo has a code graph, and it was not built from your worktree.**\n" +
147
- " Call `list_projects` first, find the single entry whose `root_path` is\n" +
148
- " exactly\n" +
149
- ` \`${path}\`\n` +
150
- " and pass that entry's `name` as the `project` argument to every graph\n" +
151
- " tool. Never pass a path, and never pass your own cwd: that clone is what\n" +
152
- " was indexed, your worktree has no index and never will, so a cwd-based\n" +
153
- " lookup answers nothing and you lose the run to grep.\n" +
407
+ " Pass `project: \"" +
408
+ graphProjectKey(path) +
409
+ "\"` to every code-graph tool — the index the reindex timer\n" +
410
+ " builds for root_path `" +
411
+ path +
412
+ "`, shown under that name by\n" +
413
+ " `list_projects`. Never pass a path, and never pass your own cwd: that\n" +
414
+ " clone is what was indexed, your worktree has no index and never will,\n" +
415
+ " so a cwd-based lookup answers nothing and you lose the run to grep.\n" +
416
+ " Never run `index_repository` yourself — the refresh that builds this\n" +
417
+ " index is conductor's, and indexing your throwaway worktree writes a\n" +
418
+ " dead index nobody reads.\n" +
154
419
  "\n" +
155
420
  " Read what it tells you as a snapshot of that clone's default branch at\n" +
156
421
  " the last reindex: it does not contain your edits, and it can be hours\n" +
@@ -160,20 +425,6 @@ export function graphHint(repo: RepoTarget): string {
160
425
  );
161
426
  }
162
427
 
163
- /** Where the generated refresh script lands: conductor state, not a unit
164
- * directory, because it is ours to regenerate and needs no root to write. */
165
- export function reindexScriptPath(): string {
166
- return join(stateDir(), `${REINDEX_UNIT}.sh`);
167
- }
168
-
169
- /** Both unit files, from the one stem `systemctl enable` will be given. */
170
- export function unitPaths(unitDir = SYSTEMD_UNIT_DIR): { service: string; timer: string } {
171
- return {
172
- service: join(unitDir, `${REINDEX_UNIT}.service`),
173
- timer: join(unitDir, `${REINDEX_UNIT}.timer`),
174
- };
175
- }
176
-
177
428
  /**
178
429
  * `git clone` for one repo's index-only clone.
179
430
  *
@@ -247,7 +498,7 @@ export function reindexScript(p: ProjectConfig): string {
247
498
  * its timer, and a service enabled on its own would run once at boot and never
248
499
  * again, which looks exactly like a working install.
249
500
  */
250
- export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath()): string {
501
+ export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath(p)): string {
251
502
  const home = homedir();
252
503
  const user = userInfo().username;
253
504
  return [
@@ -315,7 +566,7 @@ export function reindexTimer(p: ProjectConfig): string {
315
566
  "Persistent=true",
316
567
  "RandomizedDelaySec=2m",
317
568
  "AccuracySec=1min",
318
- `Unit=${REINDEX_UNIT}.service`,
569
+ `Unit=${reindexUnitName(p)}.service`,
319
570
  "",
320
571
  "[Install]",
321
572
  "WantedBy=timers.target",
@@ -334,11 +585,11 @@ export function reindexTimer(p: ProjectConfig): string {
334
585
  * So generation runs unprivileged as the fleet user, and only the copy into
335
586
  * the unit directory is elevated.
336
587
  */
337
- export function installCommands(unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
338
- const { service, timer } = unitPaths(from);
588
+ export function installCommands(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
589
+ const { service, timer } = unitPaths(p, from);
339
590
  return [
340
591
  `sudo install -m 0644 ${service} ${timer} ${unitDir}/`,
341
- `sudo systemctl daemon-reload && sudo systemctl enable --now ${REINDEX_UNIT}.timer`,
592
+ `sudo systemctl daemon-reload && sudo systemctl enable --now ${reindexUnitName(p)}.timer`,
342
593
  ];
343
594
  }
344
595
 
@@ -347,9 +598,9 @@ function block(title: string, body: string): string[] {
347
598
  }
348
599
 
349
600
  /**
350
- * The whole plan as text, with nothing done. This is the default mode of
351
- * `graph-setup`, and it is a plan an operator can read, paste, or ignore —
352
- * including on a host where they are not root and `--write` would fail.
601
+ * The whole plan as text, with nothing done. This is the `--print` mode of
602
+ * `setup graph`, and it is a plan an operator can read, paste, or ignore —
603
+ * including on a host where they are not root and the install would fail.
353
604
  */
354
605
  export function formatGraphSetup(
355
606
  p: ProjectConfig,
@@ -434,25 +685,63 @@ export function formatGraphSetup(
434
685
  "",
435
686
  );
436
687
 
437
- const script = reindexScriptPath();
438
- const { service, timer } = unitPaths(stateDir());
688
+ const script = reindexScriptPath(p);
689
+ const { service, timer } = unitPaths(p, stateDir());
439
690
  lines.push(
440
691
  ` \`omp-conductor setup graph\` writes these three files for you, all under`,
441
- ` ${stateDir()}. Run it as the account the fleet runs as — never under`,
442
- " sudo, which would resolve the config, the state directory and the unit's",
443
- " own User= as root and quietly build indexes no worker can read.",
692
+ ` ${stateDir()} — named for this project, so another project's files on the`,
693
+ " same host are never touched. Run it as the account the fleet runs as —",
694
+ " never under sudo, which would resolve the config, the state directory and",
695
+ " the unit's own User= as root and quietly build indexes no worker can read.",
444
696
  "",
445
697
  ...block(script, reindexScript(p)),
446
698
  ...block(service, reindexService(p, script)),
447
699
  ...block(timer, reindexTimer(p)),
448
700
  ` then install them, which is the only step that needs root:`,
449
701
  "",
450
- ...installCommands(unitDir).map((c) => ` ${c}`),
702
+ ...installCommands(p, unitDir).map((c) => ` ${c}`),
451
703
  );
452
704
 
705
+ // An artefact another project (or nothing of ours) already owns at this
706
+ // project's stem: the run would refuse rather than overwrite it, and the
707
+ // plan must say so before the operator answers a consent prompt they will
708
+ // not be asked (#720).
709
+ const conflict = stagingConflict(p, [script, service, timer]);
710
+ if (conflict !== undefined) {
711
+ lines.push(
712
+ "",
713
+ ` NOTE: ${conflict.path} already exists and was ${
714
+ conflict.owner === undefined ? "not generated by `setup graph`" : `generated for project "${conflict.owner}"`
715
+ } —`,
716
+ " this run refuses to overwrite it. Delete the file and re-run.",
717
+ );
718
+ }
719
+
720
+ // A host that ran the pre-#720 project-less version keeps its old files:
721
+ // their timer refreshes whichever project generated it last, forever. The
722
+ // plan names the remediation because it is host action this command must
723
+ // not take for the operator.
724
+ const legacy = legacyReindexFiles();
725
+ if (legacy.length > 0) {
726
+ lines.push("", " NOTE: " + legacyReindexNote(legacy).replace(/\n/g, "\n "));
727
+ }
728
+
453
729
  return lines.join("\n");
454
730
  }
455
731
 
732
+ /** One rendered artefact: where it lands and exactly what would be written. */
733
+ export interface GraphSetupFile {
734
+ path: string;
735
+ content: string;
736
+ }
737
+
738
+ /** The plan's three artefacts. */
739
+ export interface GraphSetupFiles {
740
+ script: GraphSetupFile;
741
+ service: GraphSetupFile;
742
+ timer: GraphSetupFile;
743
+ }
744
+
456
745
  /** What staging wrote, and the root-only steps it deliberately left. */
457
746
  export interface GraphSetupWrite {
458
747
  written: string[];
@@ -460,26 +749,20 @@ export interface GraphSetupWrite {
460
749
  }
461
750
 
462
751
  /**
463
- * Writes the script and both units, and returns what to do next.
464
- *
465
- * Deliberately stops there. Running `systemctl` would need root the wizard and
466
- * the CLI may not have, and a package that enables system timers behind an
467
- * operator's back is one you cannot audit by reading its output.
752
+ * Everything `setup graph` would stage, rendered and nothing written: the
753
+ * three files' paths and bytes, plus the `next` text naming the root-only
754
+ * steps. The consent prompt prints this, and only the consent-gated step
755
+ * turns it into files — so the prompt's "stages on confirm" is true of the
756
+ * staged tree when it is printed, and a declined run leaves every staged
757
+ * file byte-identical (#720, mirroring #510's deferral for `setup host`).
468
758
  */
469
- export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupWrite {
470
- const script = reindexScriptPath();
759
+ export function planGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupFiles & { next: string } {
760
+ const script = { path: reindexScriptPath(p), content: reindexScript(p) };
471
761
  // All three land in the state directory, which this account owns — so the
472
762
  // whole command runs unprivileged and there is no sudo path that could
473
763
  // resolve HOME, the config or the unit's User= as the wrong account.
474
- const { service, timer } = unitPaths(stateDir());
475
-
476
- mkdirSync(dirname(script), { recursive: true });
477
- writeFileSync(script, reindexScript(p));
478
- // Executable so an operator can run the refresh by hand before trusting a
479
- // timer with it; the unit calls bash explicitly either way.
480
- chmodSync(script, 0o755);
481
- writeFileSync(service, reindexService(p, script));
482
- writeFileSync(timer, reindexTimer(p));
764
+ const service = { path: unitPaths(p, stateDir()).service, content: reindexService(p, script.path) };
765
+ const timer = { path: unitPaths(p, stateDir()).timer, content: reindexTimer(p) };
483
766
 
484
767
  const missing = graphRepos(p).filter((r) => !existsSync(r.graphProject));
485
768
  const next = [
@@ -488,11 +771,11 @@ export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): G
488
771
  "",
489
772
  "to install them, which is the only privileged step:",
490
773
  "",
491
- ...installCommands(unitDir).map((c) => ` ${c}`),
774
+ ...installCommands(p, unitDir).map((c) => ` ${c}`),
492
775
  "",
493
776
  "then watch one real run before trusting the schedule (it takes minutes per repo):",
494
777
  "",
495
- ` sudo systemctl start ${REINDEX_UNIT}.service && systemctl status ${REINDEX_UNIT}.service`,
778
+ ` sudo systemctl start ${reindexUnitName(p)}.service && systemctl status ${reindexUnitName(p)}.service`,
496
779
  ...(missing.length === 0
497
780
  ? []
498
781
  : [
@@ -504,5 +787,32 @@ export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): G
504
787
  ]),
505
788
  ].join("\n");
506
789
 
507
- return { written: [script, service, timer], next };
790
+ return { script, service, timer, next };
791
+ }
792
+
793
+ /**
794
+ * Writes the script and both units, and returns what to do next.
795
+ *
796
+ * Deliberately stops there. Running `systemctl` would need root the wizard and
797
+ * the CLI may not have, and a package that enables system timers behind an
798
+ * operator's back is one you cannot audit by reading its output.
799
+ *
800
+ * Refuses an artefact that already exists and is not this project's own — a
801
+ * collision between two names that fold to one stem is refused rather than
802
+ * silently overwritten (#720).
803
+ */
804
+ export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupWrite {
805
+ const plan = planGraphSetup(p, unitDir);
806
+ const conflict = stagingConflict(p, [plan.script.path, plan.service.path, plan.timer.path]);
807
+ if (conflict !== undefined) throw new Error(graphConflictMessage(conflict));
808
+
809
+ mkdirSync(dirname(plan.script.path), { recursive: true });
810
+ writeFileSync(plan.script.path, plan.script.content);
811
+ // Executable so an operator can run the refresh by hand before trusting a
812
+ // timer with it; the unit calls bash explicitly either way.
813
+ chmodSync(plan.script.path, 0o755);
814
+ writeFileSync(plan.service.path, plan.service.content);
815
+ writeFileSync(plan.timer.path, plan.timer.content);
816
+
817
+ return { written: [plan.script.path, plan.service.path, plan.timer.path], next: plan.next };
508
818
  }
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import {
5
+ OMP_HARNESS_PACKAGE,
6
+ OMP_NATIVES_PACKAGE,
7
+ packageNodeModulesRoot,
8
+ } from "./host.ts";
9
+
10
+ export interface HarnessAttestation {
11
+ path: string;
12
+ version: string;
13
+ nativePath: string;
14
+ }
15
+
16
+ /** Resolve the installed peer from this package's directory, never Bun's ambient cache. */
17
+ export function resolveHarnessEntry(moduleDir: string = import.meta.dir): string {
18
+ return Bun.resolveSync(OMP_HARNESS_PACKAGE, moduleDir);
19
+ }
20
+
21
+ /** The installed peer version belonging to a resolved harness entry. */
22
+ export function harnessVersion(entry: string): string | undefined {
23
+ const root = packageNodeModulesRoot(entry);
24
+ if (root === undefined) return undefined;
25
+ try {
26
+ const parsed: unknown = JSON.parse(
27
+ readFileSync(join(root, OMP_HARNESS_PACKAGE, "package.json"), "utf8"),
28
+ );
29
+ if (parsed === null || typeof parsed !== "object") return undefined;
30
+ const version = Reflect.get(parsed, "version");
31
+ return typeof version === "string" && version !== "" ? version : undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Exercise the worker's real import contract: explicitly anchored peer and
39
+ * native-addon resolution, with the caller responsible for passing
40
+ * `--no-install` so neither import can fall through to Bun's package cache.
41
+ */
42
+ export async function probeHarness(moduleDir: string = import.meta.dir): Promise<HarnessAttestation> {
43
+ const path = resolveHarnessEntry(moduleDir);
44
+ await import(path);
45
+ const version = harnessVersion(path);
46
+ if (version === undefined) throw new Error(`cannot read the harness version for ${path}`);
47
+ const nativePath = Bun.resolveSync(OMP_NATIVES_PACKAGE, dirname(path));
48
+ await import(nativePath);
49
+ return { path, version, nativePath };
50
+ }
51
+
52
+ if (import.meta.main) {
53
+ try {
54
+ process.stdout.write(`${JSON.stringify(await probeHarness())}\n`);
55
+ } catch (cause) {
56
+ process.stderr.write(`${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}\n`);
57
+ process.exitCode = 1;
58
+ }
59
+ }