omp-conductor 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -524,6 +524,92 @@ honour the pattern it says so, and the daemon logs that per run:
524
524
  Worth reading the log for. A run that quietly used a weaker model than you chose
525
525
  otherwise looks like a run that was merely unlucky.
526
526
 
527
+ ## Code-graph discovery
528
+
529
+ Optional, off unless you answer yes in the wizard, and worth answering yes to for
530
+ one measured reason: **workers spend most of a run finding code, not changing it.**
531
+ On the dogfood fleet a single run typically spends 30–62 `read` calls and 32–69
532
+ `bash` calls against 9–24 edits — 215–390k characters of tool output, roughly four
533
+ fifths of a 120-turn budget — and the runs that died at the turns cap died with
534
+ the work unfinished. A code graph answers "who calls this" and "where is this
535
+ defined" in one call instead of twenty greps.
536
+
537
+ ### Two things this package does not do for you
538
+
539
+ `omp-conductor` never installs, spawns, imports or depends on the indexer — with
540
+ `graphProject` unset, nothing about dispatch, caps or escalation changes. That
541
+ means a fresh host needs both of these before an index is worth anything, and
542
+ `graph-setup` reports them as step 0:
543
+
544
+ 1. **`codebase-memory-mcp` on PATH** — a separate project,
545
+ [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp).
546
+ 2. **Mounted as an MCP server** in `~/.omp/agent/mcp.json`, on the account the
547
+ daemon runs as. Miss this and the failure is silent: every index builds
548
+ correctly, no worker session can read any of them, so workers fall back to
549
+ grepping and the feature looks like a no-op. `graph-setup` prints the entry.
550
+
551
+ Say yes and the wizard asks for one root, then derives one clone per routed repo
552
+ underneath it (default `~/.cache/conductor-graph/<org>/<repo>`) and writes it to
553
+ each repo's [`graphProject`](#configuration). Nothing else changes: this package
554
+ never runs an indexer, never imports one, and behaves identically with the graph
555
+ server absent — dispatch, caps and escalation do not know it exists.
556
+
557
+ ### Why the clone, and not your checkout or the worktree
558
+
559
+ This is the part that decides whether the feature helps or hurts, so it is worth
560
+ being blunt about all three candidates.
561
+
562
+ | Directory | Why not |
563
+ | --- | --- |
564
+ | **The worker's worktree** | An index is keyed by the realpath of the directory it was built from, and has no git-worktree awareness. A run's `worktrees/<issue>` path is therefore *always* an empty project — a worker that queried its own cwd would get silence, conclude there is no graph, and spend the run grepping. This is why `graphProject` is an absolute path in the config and not something derived at run time. |
565
+ | **Your own checkout** | Refreshing an index means resetting the clone to its default branch. In a directory you work in, that either destroys uncommitted work or — if it is made safe instead — indexes whatever feature branch you left checked out, so the fleet orients against your WIP. |
566
+ | **A conductor mirror** | The daemon's mirrors are bare. There is no working tree to index. |
567
+
568
+ So `graphProject` names a fourth thing: a clone that exists only to be indexed,
569
+ that nothing human ever edits, and that is therefore safe to `git reset --hard`
570
+ every night. The worker brief names that path, tells the session to match it
571
+ against `list_projects`' `root_path` and query by the `name` beside it, and says
572
+ plainly that the graph is a snapshot which does **not** contain the worker's own
573
+ edits — orient with it, then read the real file before changing it.
574
+
575
+ ### Creating and refreshing them
576
+
577
+ ```bash
578
+ omp-conductor graph-setup # print the plan: clones, index commands, units
579
+ omp-conductor graph-setup --write # stage the script and the two units (no root)
580
+ ```
581
+
582
+ `graph-setup` prints a `git clone` for every clone that does not exist yet, the
583
+ one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer`
584
+ pair built from the project's own repos and branches. `--write` stages all three
585
+ in the state directory and prints the two `sudo` lines that install and enable
586
+ them; it never runs `systemctl`.
587
+
588
+ **Run it as the account the fleet runs as, never under `sudo`** — it refuses if
589
+ you try. Everything it derives resolves per-account: the config it loads, the
590
+ state directory it stages into, and the `HOME`/`User=` it bakes into the unit.
591
+ Under root you get a timer that goes green while writing indexes into
592
+ `/root/.cache`, where no worker session looks — silent, and indistinguishable
593
+ from the feature simply not helping. Only installing the units needs root, which
594
+ is why that is two separate printed commands.
595
+
596
+ Two properties of the generated unit are deliberate:
597
+
598
+ - **It is a timer, not the server's own watcher.** That watcher lives inside a
599
+ connected MCP session and dies with it, so an ephemeral worker session keeps
600
+ nothing fresh. The refresh has to come from outside the fleet.
601
+ - **It fails loudly.** The refresh is `set -euo pipefail`, then per repo
602
+ `git fetch --prune origin` and `git reset --hard origin/<its own defaultBranch>`
603
+ before indexing. Nothing is `|| true`-ed, so a fetch that has been broken for a
604
+ week turns the unit red instead of quietly re-indexing a stale tree and exiting
605
+ `0` — a green timer serving a month-old graph is worse than no graph at all.
606
+
607
+ The unit spells out `HOME` and an explicit `PATH`, because systemd supplies
608
+ neither usefully: the indexer resolves its store from `HOME`, systemd's default
609
+ `PATH` has no `~/.local/bin`, and the indexer shells out to `git`. Both are the
610
+ user that ran `graph-setup`; the unit sets no `User=`, so check them if that is
611
+ not the account the timer runs as.
612
+
527
613
  ## Escalation tiers
528
614
 
529
615
  | Tier | Meaning | Raised by | Delivered to |
@@ -638,7 +724,8 @@ A complete, valid config for one project with two target repos:
638
724
  "gates": [
639
725
  { "cmd": "bun run lint", "cwd": "." },
640
726
  { "cmd": "bun test", "cwd": "." }
641
- ]
727
+ ],
728
+ "graphProject": "~/.cache/conductor-graph/acme/api"
642
729
  },
643
730
  "worker": {
644
731
  "name": "worker",
@@ -687,6 +774,7 @@ Field notes:
687
774
  | `routing.labelPrefix` | Optional; defaults to `repo:`. |
688
775
  | `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
689
776
  | `gates` | The exact cheap commands CI also runs, each with the `cwd` it runs from (`cwd` defaults to `.`). Running the real gate locally is what makes an unattended push safe — a subset lets an error outside the source dir reach the runners. |
777
+ | `graphProject` | Optional, per repo. Absolute path of the **index-only clone** whose code graph this repo's workers query — conductor's own disposable clone, pinned to the repo's default branch, never a checkout you work in and never a worker's worktree. Written by the wizard; `~` is expanded, and a relative path is an error rather than something resolved against whichever cwd happened to read the file. Absent means this repo has no graph and its briefs say nothing about one. See [Code-graph discovery](#code-graph-discovery). |
690
778
  | `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
691
779
  | `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
692
780
  | `escalation.orchestrator` | Optional; `"embedded"` (default) or `"external"`. `external` means an orchestrator session already runs elsewhere: the daemon starts none, and tier-1 escalations post as issue comments for that session to drain. Any other value is an error. |
@@ -863,6 +951,7 @@ omp-conductor unblock <issue> [--project NAME]
863
951
  omp-conductor daemon [--once] [--port N] [--project NAME]
864
952
  omp-conductor pause
865
953
  omp-conductor resume
954
+ omp-conductor graph-setup [--project NAME] [--write]
866
955
  omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
867
956
  omp-conductor help
868
957
  ```
@@ -881,6 +970,8 @@ omp-conductor help
881
970
  | `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
882
971
  | `pause` | Stop claiming new work. The running daemon notices on its next tick; runs already in flight finish. The orchestrator heartbeat keeps ticking — its gate is the arm marker, not this flag. |
883
972
  | `resume` | Allow claiming again. |
973
+ | `graph-setup` | Print how to set up the code-graph indexes workers query instead of grepping: a `git clone` for every index-only clone that does not exist yet, the one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer` pair generated from the project's own repos and branches. Reads only, so it is safe on a host where you are not root. Exits `1` when no repo in the project has [`graphProject`](#configuration) set, because the fix is a wizard answer rather than a flag. See [Code-graph discovery](#code-graph-discovery). |
974
+ | `--write` | Only for `graph-setup`. Writes the refresh script into the state directory and the two units into `/etc/systemd/system`, then prints the exact `systemctl daemon-reload && systemctl enable --now cbm-reindex.timer` to run. It never runs `systemctl` itself and never enables anything: that needs root, and a package that enables system timers behind your back is one you cannot audit by reading its output. |
884
975
  | `brief-upgrade` | Compare a project's `ORCHESTRATOR.md` against the brief this version of the package ships. Reports by default; see [Keeping a brief current](#keeping-a-brief-current). |
885
976
  | `--apply` | Only for `brief-upgrade`. Replaces the half above the `YOURS TO EDIT` banner and keeps everything below it, backing the previous file up first. Ignored when the brief cannot be split or the template is unrendered. |
886
977
  | `--file PATH` | Only for `brief-upgrade`. Check a brief that is not where the wizard would have put it, on a host that may have no config at all. |
@@ -921,6 +1012,7 @@ dispatcher. The brief is explicit about the boundary:
921
1012
  | It may | It must not |
922
1013
  | --- | --- |
923
1014
  | Read the issue and the repo's own guidance (`AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, relevant ADRs) before writing anything. | Touch any path outside its worktree, or switch branches. |
1015
+ | Query its repo's [code graph](#code-graph-discovery), when one is configured, by the project name whose `root_path` matches the clone its brief names. | Query that graph by its own cwd or worktree path — no index of a worktree exists — or treat what it returns as current. It is a snapshot of the clone's default branch; the real file in the worktree wins. |
924
1016
  | Edit code inside its own worktree. | Weaken, skip, delete or loosen **any test it did not write** — that is a design question to escalate, and it is checked by diff review before the push. |
925
1017
  | Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
926
1018
  | Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor-onboarding
3
- description: Interview-driven onboarding for omp-conductor. Use when the user wants to set up conductor, onboard a new fleet or project, configure the fleet, asks for conductor setup help, asks what belongs in ORCHESTRATOR.md, or wants an agent's release and merge authority scoped and written down. Interviews the operator on release policy, escalation taste and reporting scope, reads each routing repo's CI to propose the real pre-push gates, learns the product and roadmap the fleet will groom, scaffolds the release procedure from the repo's own release workflows rather than from the operator's memory, tailors ORCHESTRATOR.md from the shipped template, verifies the worker brief's assumptions against the actual repos, then finishes through the deterministic /conductor setup wizard.
3
+ description: Interview-driven onboarding for omp-conductor. Use when the user wants to set up conductor, onboard a new fleet or project, configure the fleet, asks for conductor setup help, asks what belongs in ORCHESTRATOR.md, or wants an agent's release and merge authority scoped and written down. Interviews the operator on release policy, escalation taste and reporting scope, reads each routing repo's CI to propose the real pre-push gates, learns the product and roadmap the fleet will groom, scaffolds the release procedure from the repo's own release workflows rather than from the operator's memory, tailors ORCHESTRATOR.md from the shipped template, verifies the worker brief's assumptions against the actual repos, finishes through the deterministic /conductor setup wizard, then builds the code-graph indexes workers query instead of grepping.
4
4
  ---
5
5
 
6
6
  # Onboarding a conductor fleet
@@ -549,9 +549,11 @@ You have the answers ready, so this is fast — and it stays the wizard's decisi
549
549
  to write, not yours. It asks, in this order: project name; tracker repo; queue
550
550
  label; whether to rename the state labels; routing label prefix; then per repo the
551
551
  routing key, clone URL, default branch and **pre-push gates** (your Step 2
552
- proposal, in `cmd @ cwd` form); whether to add another repo; caps; the Telegram
553
- chat id for tier 2; the escalation fallback; the report scope; and finally whether
554
- to write `ORCHESTRATOR.md`.
552
+ proposal, in `cmd @ cwd` form); whether to add another repo; whether to set up
553
+ **code-graph discovery** and the root its clones live under (Step 8); caps; the
554
+ authority confirms; the worker model; the Telegram chat id for tier 2; the
555
+ escalation fallback; whether an orchestrator session already runs elsewhere; the
556
+ report scope; and finally whether to write `ORCHESTRATOR.md`.
555
557
 
556
558
  Two things about the end of it that you must not smooth over:
557
559
 
@@ -606,7 +608,98 @@ Operators conflate these, and the failure modes are not the same.
606
608
 
607
609
  ---
608
610
 
609
- ## Step 8 — hand over the learning loop
611
+ ## Step 8 — build the code graph, if they said yes to it
612
+
613
+ Only if the wizard's code-graph question was answered yes. It is optional, and a
614
+ fleet without it works exactly as it did before — but it is the cheapest single
615
+ improvement to how far a worker gets, so lead with the number: **workers spend
616
+ most of a run finding code, not changing it.** Measured on the reference fleet, a
617
+ run typically spends 30–62 `read` and 32–69 `bash` calls against 9–24 edits, and
618
+ the runs that hit the turns cap hit it with the work unfinished. A graph answers
619
+ "who calls this" in one call instead of twenty greps.
620
+
621
+ Say the thing operators get wrong before you run anything: **the indexed
622
+ directories are conductor's, not theirs.** Three candidates and only one works.
623
+
624
+ - A worker's **worktree** cannot be indexed usefully — an index is keyed by the
625
+ realpath it was built from, so a throwaway `worktrees/<issue>` path is always an
626
+ empty project. That is why the brief hands the worker an absolute path instead.
627
+ - Their **own checkout** must not be indexed. Refreshing means resetting to the
628
+ default branch, which in a directory they work in either destroys uncommitted
629
+ work or indexes the feature branch they left checked out.
630
+ - Conductor's **mirrors** are bare. No working tree, nothing to index.
631
+
632
+ So each `graphProject` is a fourth thing: a disposable clone that exists only to
633
+ be indexed, pinned to the repo's default branch, never edited by a human. Say that
634
+ out loud, because an operator who points it at `~/projects/<repo>` to "save disk"
635
+ has armed something that will one day `git reset --hard` over their work.
636
+
637
+ Two host prerequisites come before any of that, and neither is conductor's to
638
+ install. `graph-setup` reports both as step 0, so run it first and read that
639
+ block before running anything else.
640
+
641
+ - **The indexer must be on PATH.** `codebase-memory-mcp` is a separate project
642
+ ([source](https://github.com/DeusData/codebase-memory-mcp)); the package never
643
+ installs, spawns or depends on it. A host without it gets command-not-found
644
+ partway down the plan.
645
+ - **It must be mounted as an MCP server for sessions**, in `~/.omp/agent/mcp.json`
646
+ on the account the daemon runs as. This is the one that bites, because it fails
647
+ *silently*: indexing succeeds, the databases are real and correct, and worker
648
+ sessions have no graph tools at all — so every worker quietly greps and the
649
+ whole thing looks like it simply did not help. `graph-setup` prints the exact
650
+ entry to paste, pointed at the binary it found.
651
+
652
+ Check the mount on the daemon's account, not yours — a per-user config that is
653
+ present for the operator and absent for the service account looks fine from the
654
+ shell they are typing in.
655
+
656
+ Then, on the host that runs the daemon:
657
+
658
+ ```bash
659
+ omp-conductor graph-setup # read-only: prints the whole plan
660
+ ```
661
+
662
+ Walk them through what it printed rather than pasting it silently. It has three
663
+ parts, and each one is a decision they can still refuse: a `git clone` per missing
664
+ clone, an index command per repo (minutes each — run them now, or the first worker
665
+ queries an empty graph), and a `cbm-reindex.service` + `cbm-reindex.timer` pair
666
+ derived from their own repos and branches. Then:
667
+
668
+ ```bash
669
+ omp-conductor graph-setup --write # stages the script and the two units (no root)
670
+ ```
671
+
672
+ **Have them run this as the account the fleet runs as, not under `sudo`.** The
673
+ command refuses sudo outright, and that refusal is the whole point: config path,
674
+ state directory, `~/.cache` and the unit's own `User=` all resolve per-account,
675
+ so a root run stages a timer that goes green while writing indexes into
676
+ `/root/.cache` where no worker session looks. It is silent, and it looks exactly
677
+ like the feature not helping.
678
+
679
+ `--write` stages all three files in the state directory and prints the two `sudo`
680
+ lines that install and enable them — installing units is the only privileged
681
+ step, and it never runs `systemctl` itself. Have them start the service once by
682
+ hand and read the result: a first real run is where a wrong branch or a missing
683
+ clone shows up, and the unit is written to fail loudly rather than index a stale
684
+ tree.
685
+
686
+ Two things to leave them with:
687
+
688
+ - **A timer, not the server's own watcher.** That watcher lives inside a connected
689
+ MCP session and dies with it, so nothing a worker mounts keeps anything fresh.
690
+ If the timer is not enabled, the graph decays and no one is told.
691
+ - **The graph is a snapshot, and the brief says so.** Workers are told to orient
692
+ with it and then read the real file before editing, because the index is the
693
+ default branch at the last reindex — not their branch, and not their edits.
694
+
695
+ Verify before moving on: `codebase-memory-mcp cli list_projects` must show one
696
+ entry per repo whose `root_path` is exactly the configured `graphProject`. That
697
+ match is the whole contract — the worker brief tells the session to find its
698
+ project by that path, so a mismatch means a silent fallback to grep.
699
+
700
+ ---
701
+
702
+ ## Step 9 — hand over the learning loop
610
703
 
611
704
  Finish by telling the operator the truth about what they just wrote:
612
705
 
@@ -39,16 +39,27 @@ files are canonical; your priors are not.
39
39
  mid-refactor loses the run. If code-graph MCP tools are mounted (a
40
40
  `codebase-memory` server or similar), start there: list its indexed projects,
41
41
  and query by **project name** — your worktree is a throwaway path the index
42
- has never seen, so a cwd-based lookup finds nothing while the canonical
43
- checkout's index has the whole call graph. Fall back to grep where the graph
42
+ has never seen, so a cwd-based lookup finds nothing while the clone that was
43
+ actually indexed has the whole call graph. Fall back to grep where the graph
44
44
  is silent. Either way, trace the real flow end to end — every file the change
45
45
  touches — and check the callers of any function you are about to change; the
46
46
  smallest diff in the wrong place is a second bug.
47
- 2. **Follow existing patterns.** A second convention beside an existing one is a
47
+ {{GRAPH_HINT}}2. **One read per file, not one per question.** A turn that reads forty lines
48
+ costs exactly what a turn that rewrites a module costs, and you have a fixed
49
+ number of them. So take every range you already know you want in a single
50
+ call — `read path.py:1-40,120-160,300-340` — rather than returning to the
51
+ same file three times as each question occurs to you. When you do not yet
52
+ know the ranges, read the file once and keep what you learned instead of
53
+ re-reading a neighbouring slice later. Measured on this fleet: one run spent
54
+ 58 reads, of which 20 were consecutive reads of the *same* file and 28 were
55
+ return visits to a file it had already opened — roughly a sixth of its whole
56
+ budget, on a run that then died with the work unfinished. The same applies to
57
+ `grep`: one pattern that answers the question beats three that narrow it.
58
+ 3. **Follow existing patterns.** A second convention beside an existing one is a
48
59
  defect. Reuse the helper that already exists rather than writing a sibling.
49
- 3. **Keep the diff small.** Small PRs merge; large ones conflict. If the issue
60
+ 4. **Keep the diff small.** Small PRs merge; large ones conflict. If the issue
50
61
  genuinely cannot be done small, stop and escalate rather than ballooning.
51
- 4. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
62
+ 5. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
52
63
  assertion, or special-case an input to make a check pass.
53
64
 
54
65
  ## Tests — read this carefully
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
11
11
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
12
12
  import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
13
+ import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
13
14
  import {
14
15
  clearRecord,
15
16
  DEFAULT_PORT,
@@ -38,6 +39,7 @@ usage:
38
39
  omp-conductor daemon [--once] [--port N] [--project NAME]
39
40
  omp-conductor pause
40
41
  omp-conductor resume
42
+ omp-conductor graph-setup [--project NAME] [--write]
41
43
  omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
42
44
  omp-conductor help
43
45
 
@@ -63,6 +65,13 @@ usage:
63
65
  and exits. This is what \`start\` launches.
64
66
  pause stop claiming new work. The running daemon notices on its next tick.
65
67
  resume allow claiming again.
68
+ graph-setup
69
+ print how to set up the code-graph indexes workers query instead of
70
+ grepping: the clone commands for any missing index-only clone, the
71
+ index command per repo, and a systemd service+timer that keeps them
72
+ current. --write writes the two units and the script they run, and
73
+ prints the systemctl line to run — it never runs systemctl itself.
74
+ Exits 1 when no repo in the project has graphProject configured.
66
75
  brief-upgrade
67
76
  compare a project's ORCHESTRATOR.md against the brief this version of
68
77
  the package ships. Reports by default; --apply replaces the half above
@@ -451,6 +460,57 @@ try {
451
460
  process.stdout.write("resumed — work will be claimed on the next tick\n");
452
461
  break;
453
462
 
463
+ case "graph-setup": {
464
+ // Refused rather than accommodated. Under sudo every path this command
465
+ // derives — the config it loads, the state directory it writes to, the
466
+ // HOME and User= it bakes into the unit — resolves as root instead of the
467
+ // fleet's account, and the result is a timer that goes green while
468
+ // building indexes in a store no worker session ever reads. Nothing about
469
+ // that announces itself, so the only safe answer is to stop.
470
+ if (process.env["SUDO_USER"] !== undefined) {
471
+ process.stderr.write(
472
+ "omp-conductor: run graph-setup as the account the fleet runs as, not under sudo.\n" +
473
+ `Under sudo the config, ~/.cache and the unit's User= all resolve as root, and the\n` +
474
+ `indexes land where no worker can read them. Only installing the units needs root,\n` +
475
+ "and this command prints those two lines for you at the end.\n",
476
+ );
477
+ process.exit(1);
478
+ }
479
+
480
+ const project = findProject(loadConfig(), flag(argv, "project"));
481
+ if (graphRepos(project).length === 0) {
482
+ // Not a warning: with nothing configured there is nothing to print, and
483
+ // the fix is a wizard answer rather than a flag on this command.
484
+ process.stderr.write(
485
+ `omp-conductor: no repo in project "${project.name}" has graphProject set — re-run\n` +
486
+ "/conductor setup and say yes to code-graph discovery.\n",
487
+ );
488
+ process.exit(1);
489
+ }
490
+
491
+ if (!argv.includes("--write")) {
492
+ process.stdout.write(`${formatGraphSetup(project)}\n`);
493
+ break;
494
+ }
495
+
496
+ let result: GraphSetupWrite;
497
+ try {
498
+ result = writeGraphSetup(project);
499
+ } catch (err) {
500
+ // No longer the permissions case — all three files go to this account's
501
+ // own state directory — so this is a full disk, a read-only mount or a
502
+ // state directory someone else owns. Say what failed and offer the
503
+ // printed plan, which is a complete substitute for the write.
504
+ process.stderr.write(
505
+ `omp-conductor: could not stage the files (${err instanceof Error ? err.message : String(err)}).\n` +
506
+ "Drop --write and copy the printed text yourself — it is the same content.\n",
507
+ );
508
+ process.exit(1);
509
+ }
510
+ process.stdout.write(`wrote:\n${result.written.map((f) => ` ${f}`).join("\n")}\n\n${result.next}\n`);
511
+ break;
512
+ }
513
+
454
514
  case "brief-upgrade": {
455
515
  // `--file` exists because a real fleet's brief is often not where the wizard
456
516
  // would have put it: the supervising session runs from its own directory, and
package/src/config.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
- import { dirname, join } from "node:path";
17
+ import { dirname, isAbsolute, join } from "node:path";
18
18
  import {
19
19
  AUTHORITY_HOLDERS,
20
20
  CONFIG_VERSION,
@@ -432,17 +432,47 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
432
432
  problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
433
433
  continue;
434
434
  }
435
- repos[key] = {
435
+ const target: RepoTarget = {
436
436
  name: pickString(value?.["name"], key),
437
437
  cloneUrl,
438
438
  defaultBranch: pickString(value?.["defaultBranch"], "main"),
439
439
  gates: normalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
440
440
  };
441
+ const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
442
+ if (graph !== undefined) target.graphProject = graph;
443
+ repos[key] = target;
441
444
  }
442
445
 
443
446
  return repos;
444
447
  }
445
448
 
449
+ /**
450
+ * The path of the index-only clone whose code graph this repo's workers query,
451
+ * or `undefined` when the repo has none.
452
+ *
453
+ * A relative path is rejected rather than resolved, and that rejection is the
454
+ * whole reason this is validated here: the value is written in one process and
455
+ * *used* in another, by a session whose cwd is its own throwaway worktree. So
456
+ * `../graph/api` would name a different directory for every reader, and none of
457
+ * them the one that was indexed. There is no cwd this file could honestly
458
+ * resolve it against, so it says so rather than guessing.
459
+ */
460
+ function normalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
461
+ if (parsed === undefined) return undefined;
462
+ if (!nonEmptyString(parsed)) {
463
+ problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
464
+ return undefined;
465
+ }
466
+
467
+ const path = expandHome(parsed.trim());
468
+ if (isAbsolute(path)) return path;
469
+ problems.push(
470
+ `${label}.graphProject must be an absolute path — it is read by sessions whose cwd is their own ` +
471
+ `worktree — found ${JSON.stringify(parsed)}`,
472
+ );
473
+ return undefined;
474
+ }
475
+
446
476
  /**
447
477
  * Gates are the pre-push CI equivalent, so a malformed entry is an error, not
448
478
  * something to drop quietly: a skipped gate is exactly how a lint failure
@@ -550,8 +580,14 @@ function pickLiteral<T extends string>(
550
580
  return hit;
551
581
  }
552
582
 
553
- /** `~/x` in a hand-written config must not create a literal `~` directory. */
554
- function expandHome(p: string): string {
583
+ /**
584
+ * `~/x` in a hand-written config must not create a literal `~` directory.
585
+ *
586
+ * Exported because the wizard and `graph-setup` derive paths the operator may
587
+ * have typed with a `~` in them, and one spelling of this rule in the package
588
+ * is the only way a path shown in a plan matches the path a validator accepts.
589
+ */
590
+ export function expandHome(p: string): string {
555
591
  if (p === "~") return homedir();
556
592
  return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
557
593
  }
package/src/daemon.ts CHANGED
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
12
12
  import { dirname, join, relative } from "node:path";
13
13
  import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
14
14
  import { createEscalator } from "./escalate.ts";
15
+ import { graphHint } from "./graph.ts";
15
16
  import { livingDaemon } from "./lifecycle.ts";
16
17
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
17
18
  import { startOrchestrator } from "./orchestrator.ts";
@@ -429,7 +430,16 @@ function endedBy(killedBy: KilledBy | undefined): string {
429
430
  return "a failed run";
430
431
  }
431
432
 
432
- async function buildBrief(
433
+ /**
434
+ * The worker's opening prompt.
435
+ *
436
+ * Exported for the same reason `salvageLines` is: this text is the entire
437
+ * context a session with the host's credentials gets, so the two things a test
438
+ * can hold it to are worth holding — that a configured graph reaches the worker,
439
+ * and that a project without one gets the brief this package has always shipped,
440
+ * to the byte.
441
+ */
442
+ export async function buildBrief(
433
443
  project: ProjectConfig,
434
444
  r: Routed,
435
445
  branch: string,
@@ -447,6 +457,10 @@ async function buildBrief(
447
457
  WORKTREE: worktree,
448
458
  ACCEPTANCE_CRITERIA: acceptanceCriteria(r.issue),
449
459
  GATES: gatesBlock(r.repo),
460
+ // Empty for a repo with no `graphProject`, and empty means *nothing*: the
461
+ // placeholder sits flush against the next list item in the template, so an
462
+ // unconfigured render leaves no blank line where a hint would have gone.
463
+ GRAPH_HINT: graphHint(r.repo),
450
464
  });
451
465
  }
452
466
 
package/src/escalate.ts CHANGED
@@ -233,22 +233,32 @@ async function sendTelegram(token: string, chatId: string, text: string): Promis
233
233
  throw new Error(`telegram sendMessage failed: ${redact(reason, token)}`);
234
234
  }
235
235
 
236
- const payload = redact(await res.text().catch(() => ""), token).slice(0, 400);
236
+ // The raw body is what gets parsed; `diagnostic` is only ever for humans.
237
+ // These were one variable once, and the bug that produced was expensive: the
238
+ // 400-char cap meant for a log line was applied first, so `JSON.parse` was
239
+ // handed a truncated object and threw. Telegram echoes the whole message back
240
+ // inside `result.text`, so every page long enough to matter overflowed and was
241
+ // reported as rejected *after being delivered* — with the dedup marker only
242
+ // written on success, that also re-sent the same page every tick. Redaction
243
+ // stays out of the parse for the same reason: it rewrites the very bytes the
244
+ // decision is read from.
245
+ const raw = await res.text().catch(() => "");
246
+ const diagnostic = redact(raw, token).slice(0, 400);
237
247
  if (!res.ok) {
238
- throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${payload}`);
248
+ throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${diagnostic}`);
239
249
  }
240
250
 
241
251
  // Telegram answers 200 with `{"ok":false}` for plenty of real failures
242
252
  // (kicked from the chat, bad chat_id), so the status alone proves nothing.
243
253
  let ok = false;
244
254
  try {
245
- const parsed: unknown = JSON.parse(payload);
255
+ const parsed: unknown = JSON.parse(raw);
246
256
  ok = typeof parsed === "object" && parsed !== null && "ok" in parsed && parsed.ok === true;
247
257
  } catch {
248
258
  ok = false;
249
259
  }
250
260
  if (!ok) {
251
- throw new Error(`telegram sendMessage rejected: ${payload}`);
261
+ throw new Error(`telegram sendMessage rejected: ${diagnostic}`);
252
262
  }
253
263
  }
254
264