omp-conductor 0.3.4 → 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 +93 -1
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +98 -5
- package/src/briefs/worker.md +16 -5
- package/src/cli.ts +60 -0
- package/src/config.ts +40 -4
- package/src/daemon.ts +15 -1
- package/src/graph.ts +508 -0
- package/src/plugin.ts +53 -1
- package/src/setup.ts +32 -0
- package/src/types.ts +25 -0
- package/src/worktree.ts +81 -2
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
|
+
"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,
|
|
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;
|
|
553
|
-
|
|
554
|
-
|
|
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 —
|
|
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
|
|
package/src/briefs/worker.md
CHANGED
|
@@ -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
|
|
43
|
-
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
554
|
-
|
|
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
|
-
|
|
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/graph.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-graph discovery: the indexes workers query instead of grepping, and the
|
|
3
|
+
* commands that create and refresh them.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists at all, measured on the dogfood fleet rather than assumed:
|
|
6
|
+
* workers spend roughly four fifths of a 120-turn budget *finding* code — 30–62
|
|
7
|
+
* `read` calls and 32–69 `bash` calls against 9–24 edits per run, 215–390k
|
|
8
|
+
* characters of tool output. A code graph answers "who calls this" and "where is
|
|
9
|
+
* this defined" in one call, which is the difference between a run that lands
|
|
10
|
+
* and a run that dies mid-refactor with the work unfinished.
|
|
11
|
+
*
|
|
12
|
+
* Two hard boundaries hold everything here together:
|
|
13
|
+
*
|
|
14
|
+
* - **This package never runs an indexer, and never depends on one.** Nothing
|
|
15
|
+
* below spawns the graph server, imports it, or checks for it; the daemon's
|
|
16
|
+
* dispatch, caps and escalation paths do not mention it. `graph-setup` prints
|
|
17
|
+
* commands, and with `--write` writes two systemd units — it does not even run
|
|
18
|
+
* `systemctl`, because the wizard and the CLI are not root and a package that
|
|
19
|
+
* silently writes root-level state is not one you can trust with a fleet.
|
|
20
|
+
* - **A worker never queries its own worktree.** An index is keyed by the
|
|
21
|
+
* realpath of the directory it was built from, with no git-worktree awareness,
|
|
22
|
+
* so a run's `worktrees/<issue>` path is always an empty project. Workers are
|
|
23
|
+
* pointed at {@link RepoTarget.graphProject} — a conductor-owned clone nothing
|
|
24
|
+
* human edits — and {@link graphHint} is the text that makes that unmissable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir, userInfo } from "node:os";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { expandHome, stateDir } from "./config.ts";
|
|
31
|
+
import type { ProjectConfig, RepoTarget } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The indexer's own CLI, invoked by name rather than by path so the generated
|
|
35
|
+
* unit's explicit `PATH` is the single place a host's install location is
|
|
36
|
+
* spelled out. `cli <tool> <json>` runs one tool without an MCP session.
|
|
37
|
+
*/
|
|
38
|
+
const INDEXER = "codebase-memory-mcp";
|
|
39
|
+
|
|
40
|
+
/** Both units and the script share this stem; `cbm` is the indexer's own prefix. */
|
|
41
|
+
export const REINDEX_UNIT = "cbm-reindex";
|
|
42
|
+
|
|
43
|
+
/** Where a system timer has to live to be enabled by `systemctl`. */
|
|
44
|
+
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
45
|
+
|
|
46
|
+
/** Where the upstream indexer lives, for an operator who has to go install it. */
|
|
47
|
+
const INDEXER_SOURCE = "https://github.com/DeusData/codebase-memory-mcp";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The two things that must be true of the *host* before any index is useful,
|
|
51
|
+
* neither of which conductor installs: the indexer has to be on PATH, and the
|
|
52
|
+
* agent has to mount it as an MCP server or worker sessions get no graph tools
|
|
53
|
+
* at all. Indexing without the mount produces a perfectly good database that
|
|
54
|
+
* nothing can read — the failure this preflight exists to make visible.
|
|
55
|
+
*/
|
|
56
|
+
export interface GraphPrereqs {
|
|
57
|
+
/** Resolved indexer path, or null when nothing on PATH answers to the name. */
|
|
58
|
+
indexer: string | null;
|
|
59
|
+
/** The agent's MCP server config, whether or not it exists yet. */
|
|
60
|
+
mcpConfig: string;
|
|
61
|
+
/** Whether that config already mounts the indexer for sessions. */
|
|
62
|
+
mounted: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reads the host. Split from {@link formatGraphSetup} so the plan stays pure. */
|
|
66
|
+
export function resolvePrereqs(home: string = homedir()): GraphPrereqs {
|
|
67
|
+
const onPath = (process.env["PATH"] ?? "")
|
|
68
|
+
.split(":")
|
|
69
|
+
.filter((d) => d !== "")
|
|
70
|
+
.map((d) => join(d, INDEXER))
|
|
71
|
+
.find((c) => existsSync(c));
|
|
72
|
+
|
|
73
|
+
const mcpConfig = join(home, ".omp", "agent", "mcp.json");
|
|
74
|
+
let mounted = false;
|
|
75
|
+
try {
|
|
76
|
+
// Any mention of the binary counts as mounted. Parsing the whole schema to
|
|
77
|
+
// decide would make this preflight fail on configs it does not understand,
|
|
78
|
+
// and a false "not mounted" costs an operator a confusing duplicate entry.
|
|
79
|
+
const raw = JSON.parse(readFileSync(mcpConfig, "utf8")) as Record<string, unknown>;
|
|
80
|
+
const servers = (raw["mcpServers"] ?? raw) as Record<string, unknown>;
|
|
81
|
+
mounted = Object.keys(servers).some((k) => k.includes(INDEXER));
|
|
82
|
+
} catch {
|
|
83
|
+
// No file, or unreadable: not mounted, and the plan says how to add it.
|
|
84
|
+
}
|
|
85
|
+
return { indexer: onPath ?? null, mcpConfig, mounted };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The `mcp.json` entry a fresh host needs, using the resolved path when known. */
|
|
89
|
+
export function mcpEntry(prereqs: GraphPrereqs): string {
|
|
90
|
+
const command = prereqs.indexer ?? `/usr/local/bin/${INDEXER}`;
|
|
91
|
+
return JSON.stringify({ [INDEXER]: { type: "stdio", command } }, null, 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Default parent of every index-only clone, under the cache directory because
|
|
96
|
+
* that is exactly what these are: derived data, disposable, re-creatable from a
|
|
97
|
+
* clone URL. Deliberately *not* `~/projects/<org>` — that is where a human's own
|
|
98
|
+
* checkouts live, and pointing a reindexer at one either destroys their
|
|
99
|
+
* uncommitted work or indexes whatever branch they left checked out.
|
|
100
|
+
*
|
|
101
|
+
* `trackerRepo` supplies the org so a fleet's clones land together, which is
|
|
102
|
+
* also the answer for the common case where the tracker and the code share one
|
|
103
|
+
* GitHub organisation.
|
|
104
|
+
*/
|
|
105
|
+
export function defaultGraphRoot(trackerRepo: string): string {
|
|
106
|
+
const org = trackerRepo.split("/")[0] ?? trackerRepo;
|
|
107
|
+
return join(homedir(), ".cache", "conductor-graph", org);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One repo's clone under a chosen root. `~` is expanded here so a path an
|
|
111
|
+
* operator typed matches the absolute path the validator accepts. */
|
|
112
|
+
export function graphProjectPath(root: string, repoName: string): string {
|
|
113
|
+
return join(expandHome(root.trim()), repoName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A repo that has a graph, narrowed so callers need no further guard. */
|
|
117
|
+
export type GraphRepo = RepoTarget & { graphProject: string };
|
|
118
|
+
|
|
119
|
+
/** The project's repos that have a graph configured, in config order. */
|
|
120
|
+
export function graphRepos(p: ProjectConfig): GraphRepo[] {
|
|
121
|
+
return Object.values(p.routing.repos).filter((r): r is GraphRepo => r.graphProject !== undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
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.
|
|
128
|
+
*
|
|
129
|
+
* The leading newline and the three-space indent are load-bearing: the
|
|
130
|
+
* 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.
|
|
133
|
+
*
|
|
134
|
+
* 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.
|
|
139
|
+
*/
|
|
140
|
+
export function graphHint(repo: RepoTarget): string {
|
|
141
|
+
const path = repo.graphProject;
|
|
142
|
+
if (path === undefined) return "";
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
"\n" +
|
|
146
|
+
" **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" +
|
|
154
|
+
"\n" +
|
|
155
|
+
" Read what it tells you as a snapshot of that clone's default branch at\n" +
|
|
156
|
+
" the last reindex: it does not contain your edits, and it can be hours\n" +
|
|
157
|
+
" behind the branch you are on. So orient with the graph, then read the\n" +
|
|
158
|
+
" real file in your worktree before you change it. If those tools are not\n" +
|
|
159
|
+
" mounted in this session, say so in your report and fall back to grep.\n"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
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
|
+
/**
|
|
178
|
+
* `git clone` for one repo's index-only clone.
|
|
179
|
+
*
|
|
180
|
+
* `--single-branch` so the working tree can only ever hold the branch the graph
|
|
181
|
+
* claims to describe, and the destination is quoted because the root is
|
|
182
|
+
* operator-typed and a space in it would otherwise clone into two directories.
|
|
183
|
+
*/
|
|
184
|
+
export function cloneCommand(r: GraphRepo): string {
|
|
185
|
+
return `git clone --single-branch --branch ${r.defaultBranch} ${r.cloneUrl} "${r.graphProject}"`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The one-shot index command, as a human would run it to seed a clone. */
|
|
189
|
+
export function indexCommand(r: GraphRepo): string {
|
|
190
|
+
return `${INDEXER} cli index_repository '{"repo_path": "${r.graphProject}"}'`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The refresh-and-reindex script both the timer and a human run.
|
|
195
|
+
*
|
|
196
|
+
* It fails loudly on purpose, and that is the one thing about it worth
|
|
197
|
+
* protecting. A first draft of this — hand-written on the live host — used
|
|
198
|
+
* `git fetch … || true; git pull --ff-only || true`, which meant a fetch that
|
|
199
|
+
* failed for a week still indexed the stale tree and still exited 0: a green
|
|
200
|
+
* timer serving a month-old graph, and workers orienting against code that no
|
|
201
|
+
* longer exists. So: `set -euo pipefail`, no swallowed failures anywhere, and
|
|
202
|
+
* the first broken repo takes the whole run non-zero where `systemctl status`
|
|
203
|
+
* and `systemctl is-failed` will report it.
|
|
204
|
+
*
|
|
205
|
+
* `git reset --hard origin/<defaultBranch>` rather than a merge or a pull is
|
|
206
|
+
* safe *because* of what these clones are — conductor's own, never edited by a
|
|
207
|
+
* human — and it is the only refresh with no failure mode of its own: no
|
|
208
|
+
* conflict, no divergence, no detached state to recover from. Each repo's own
|
|
209
|
+
* configured branch is used, because a fleet with a `master` repo in it would
|
|
210
|
+
* otherwise silently index nothing.
|
|
211
|
+
*/
|
|
212
|
+
export function reindexScript(p: ProjectConfig): string {
|
|
213
|
+
const lines = [
|
|
214
|
+
"#!/usr/bin/env bash",
|
|
215
|
+
`# Refresh and reindex the code graphs for omp-conductor project "${p.name}".`,
|
|
216
|
+
"#",
|
|
217
|
+
"# Generated by \`omp-conductor graph-setup\`. Regenerate it rather than editing:",
|
|
218
|
+
"# the repo list, branches and paths all come from that project's config.json.",
|
|
219
|
+
"#",
|
|
220
|
+
"# Every clone below is conductor's own, index-only and never edited by a human,",
|
|
221
|
+
"# which is what makes the hard reset safe. Do not point one at a checkout you",
|
|
222
|
+
"# work in: the reset would destroy uncommitted work.",
|
|
223
|
+
"#",
|
|
224
|
+
"# Fails loud and stops at the first problem, deliberately. A refresh that",
|
|
225
|
+
"# swallowed its errors would index a stale tree and still exit 0 — a green",
|
|
226
|
+
"# timer serving a month-old graph is worse than no graph at all.",
|
|
227
|
+
"set -euo pipefail",
|
|
228
|
+
"",
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
for (const r of graphRepos(p)) {
|
|
232
|
+
lines.push(
|
|
233
|
+
`# ${r.name} — ${r.cloneUrl} @ ${r.defaultBranch}`,
|
|
234
|
+
`cd "${r.graphProject}"`,
|
|
235
|
+
"git fetch --prune origin",
|
|
236
|
+
`git reset --hard origin/${r.defaultBranch}`,
|
|
237
|
+
indexCommand(r),
|
|
238
|
+
"",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The service half. `Type=oneshot` with no `[Install]` section: it is started by
|
|
247
|
+
* its timer, and a service enabled on its own would run once at boot and never
|
|
248
|
+
* again, which looks exactly like a working install.
|
|
249
|
+
*/
|
|
250
|
+
export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath()): string {
|
|
251
|
+
const home = homedir();
|
|
252
|
+
const user = userInfo().username;
|
|
253
|
+
return [
|
|
254
|
+
"[Unit]",
|
|
255
|
+
`Description=Reindex the code graphs omp-conductor project "${p.name}" hands its workers`,
|
|
256
|
+
"After=network-online.target",
|
|
257
|
+
"Wants=network-online.target",
|
|
258
|
+
"",
|
|
259
|
+
"[Service]",
|
|
260
|
+
"Type=oneshot",
|
|
261
|
+
`ExecStart=/bin/bash ${scriptPath}`,
|
|
262
|
+
"# Pinned to the account that generated this, and it has to be the account the",
|
|
263
|
+
"# fleet runs as. A systemd service defaults to root, and root is wrong three",
|
|
264
|
+
"# ways at once here: the indexer would write its store under /root/.cache",
|
|
265
|
+
"# where no worker session ever looks, a private clone would fetch with root's",
|
|
266
|
+
"# SSH credentials rather than the fleet's, and every path below points into a",
|
|
267
|
+
"# different account's home. All three fail silently — the timer goes green",
|
|
268
|
+
"# and the graph a worker queries is simply never the graph this built.",
|
|
269
|
+
`User=${user}`,
|
|
270
|
+
"# Both of these are spelled out because systemd supplies neither usefully.",
|
|
271
|
+
`# The indexer resolves its store from HOME (${join(home, ".cache", "codebase-memory-mcp")}),`,
|
|
272
|
+
"# so an unset HOME would build a second index nobody queries; and systemd's",
|
|
273
|
+
"# default PATH has no ~/.local/bin, while the indexer itself shells out to git.",
|
|
274
|
+
`Environment=HOME=${home}`,
|
|
275
|
+
`Environment=PATH=${["/.local/bin", "/.bun/bin"].map((d) => join(home, d)).join(":")}` +
|
|
276
|
+
":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
277
|
+
"# Indexing is CPU- and IO-heavy, and this host is also running the fleet whose",
|
|
278
|
+
"# workers read the result.",
|
|
279
|
+
"Nice=10",
|
|
280
|
+
"IOSchedulingClass=idle",
|
|
281
|
+
"# A wedged index must fail rather than hold its timer open indefinitely.",
|
|
282
|
+
"TimeoutStartSec=1h",
|
|
283
|
+
"",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The timer half — and the reason there is a timer at all.
|
|
289
|
+
*
|
|
290
|
+
* The graph server's own auto-watch lives inside a connected MCP session and
|
|
291
|
+
* dies with it, and v0.9.0 ships no daemon. An ephemeral worker session
|
|
292
|
+
* therefore keeps nothing fresh: whatever it mounts, it un-mounts minutes later.
|
|
293
|
+
* So the refresh has to come from outside the fleet entirely, on a schedule
|
|
294
|
+
* nothing in a run can influence.
|
|
295
|
+
*/
|
|
296
|
+
export function reindexTimer(p: ProjectConfig): string {
|
|
297
|
+
return [
|
|
298
|
+
"[Unit]",
|
|
299
|
+
`Description=Periodic code-graph reindex for omp-conductor project "${p.name}"`,
|
|
300
|
+
"",
|
|
301
|
+
"[Timer]",
|
|
302
|
+
"# Twenty minutes, because the measured cost is small and the cost of",
|
|
303
|
+
"# staleness is not: refreshing four repos takes ~40s of CPU, which at this",
|
|
304
|
+
"# interval is roughly 3% of one core, and the service runs at Nice=10 with",
|
|
305
|
+
"# idle IO so it yields to the fleet. A nightly refresh would be cheaper and",
|
|
306
|
+
"# much worse — a fleet merging several PRs a day would spend most of its",
|
|
307
|
+
"# dispatches querying a graph that predates the code the worker was sent to",
|
|
308
|
+
"# change, which is the one way this feature actively misleads. Lengthen it",
|
|
309
|
+
"# for quiet repos; the brief has workers verify against the real file",
|
|
310
|
+
"# regardless, so staleness degrades the graph rather than making it lie.",
|
|
311
|
+
"OnBootSec=3min",
|
|
312
|
+
"OnUnitActiveSec=20min",
|
|
313
|
+
"# Persistent catches a host that was down; the jitter keeps every install",
|
|
314
|
+
"# of this unit off the same second.",
|
|
315
|
+
"Persistent=true",
|
|
316
|
+
"RandomizedDelaySec=2m",
|
|
317
|
+
"AccuracySec=1min",
|
|
318
|
+
`Unit=${REINDEX_UNIT}.service`,
|
|
319
|
+
"",
|
|
320
|
+
"[Install]",
|
|
321
|
+
"WantedBy=timers.target",
|
|
322
|
+
"",
|
|
323
|
+
].join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The privileged tail, and the only part of this feature that needs root.
|
|
328
|
+
*
|
|
329
|
+
* Split out deliberately. Running the whole CLI under `sudo` looks convenient
|
|
330
|
+
* and is wrong: `loadConfig`, `stateDir`, `homedir` and `userInfo` would all
|
|
331
|
+
* resolve as root, so the config would be missed or the wrong one, the script
|
|
332
|
+
* would land in root's state directory, and the generated unit would bake
|
|
333
|
+
* root's HOME with no `User=` — indexes written where no worker reads them.
|
|
334
|
+
* So generation runs unprivileged as the fleet user, and only the copy into
|
|
335
|
+
* the unit directory is elevated.
|
|
336
|
+
*/
|
|
337
|
+
export function installCommands(unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
|
|
338
|
+
const { service, timer } = unitPaths(from);
|
|
339
|
+
return [
|
|
340
|
+
`sudo install -m 0644 ${service} ${timer} ${unitDir}/`,
|
|
341
|
+
`sudo systemctl daemon-reload && sudo systemctl enable --now ${REINDEX_UNIT}.timer`,
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function block(title: string, body: string): string[] {
|
|
346
|
+
return [`--- ${title} ---`, "", body.trimEnd(), ""];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
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.
|
|
353
|
+
*/
|
|
354
|
+
export function formatGraphSetup(
|
|
355
|
+
p: ProjectConfig,
|
|
356
|
+
unitDir = SYSTEMD_UNIT_DIR,
|
|
357
|
+
prereqs: GraphPrereqs = resolvePrereqs(),
|
|
358
|
+
): string {
|
|
359
|
+
const repos = graphRepos(p);
|
|
360
|
+
const missing = repos.filter((r) => !existsSync(r.graphProject));
|
|
361
|
+
// Seeded with 0 so these are still widths when the caller ignored the exit-1
|
|
362
|
+
// guard and asked for a plan for a project with no graph at all.
|
|
363
|
+
const nameWidth = Math.max(0, ...repos.map((r) => r.name.length));
|
|
364
|
+
const pathWidth = Math.max(0, ...repos.map((r) => r.graphProject.length));
|
|
365
|
+
|
|
366
|
+
const lines = [
|
|
367
|
+
`code-graph discovery for project "${p.name}"`,
|
|
368
|
+
"",
|
|
369
|
+
"Workers spend most of a run finding code rather than changing it. These",
|
|
370
|
+
'indexes answer "who calls this" and "where is this defined" in one call, so',
|
|
371
|
+
"the turns go into the work instead. Nothing below has been run.",
|
|
372
|
+
"",
|
|
373
|
+
`repos with a graph configured (${repos.length}):`,
|
|
374
|
+
];
|
|
375
|
+
for (const r of repos) {
|
|
376
|
+
// Padded so the (missing) markers line up: which clones do not exist yet is
|
|
377
|
+
// the one thing an operator scans this list for.
|
|
378
|
+
const marker = missing.includes(r) ? " (missing)" : "";
|
|
379
|
+
lines.push(` ${r.name.padEnd(nameWidth)} ${r.graphProject.padEnd(marker === "" ? 0 : pathWidth)}${marker}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Before anything else, because both of these are host state conductor does
|
|
383
|
+
// not own and neither failure is self-announcing: a missing binary surfaces
|
|
384
|
+
// as command-not-found halfway down the plan, and a missing mount surfaces
|
|
385
|
+
// as workers that never mention the graph and quietly grep instead.
|
|
386
|
+
lines.push("", "0. host prerequisites", "");
|
|
387
|
+
lines.push(
|
|
388
|
+
prereqs.indexer === null
|
|
389
|
+
? ` [ ] ${INDEXER} is NOT on your PATH. Install it first — conductor never
|
|
390
|
+
does, and never depends on it: ${INDEXER_SOURCE}`
|
|
391
|
+
: ` [x] indexer: ${prereqs.indexer}`,
|
|
392
|
+
);
|
|
393
|
+
if (prereqs.mounted) {
|
|
394
|
+
lines.push(` [x] mounted for sessions in ${prereqs.mcpConfig}`);
|
|
395
|
+
} else {
|
|
396
|
+
lines.push(
|
|
397
|
+
` [ ] NOT mounted as an MCP server, so worker sessions have no graph`,
|
|
398
|
+
` tools and every index below would be unreadable. Add to`,
|
|
399
|
+
` ${prereqs.mcpConfig}:`,
|
|
400
|
+
"",
|
|
401
|
+
...mcpEntry(prereqs)
|
|
402
|
+
.split("\n")
|
|
403
|
+
.map((l) => ` ${l}`),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
lines.push("", "1. create the clones that are missing", "");
|
|
408
|
+
if (missing.length === 0) {
|
|
409
|
+
lines.push(" every clone above already exists — nothing to create.");
|
|
410
|
+
} else {
|
|
411
|
+
lines.push(
|
|
412
|
+
" These are conductor's, not yours. Nothing human edits them, which is what",
|
|
413
|
+
" makes step 3's hard reset both safe and deterministic — so never point a",
|
|
414
|
+
" graphProject at a checkout you work in.",
|
|
415
|
+
"",
|
|
416
|
+
);
|
|
417
|
+
for (const r of missing) lines.push(` ${cloneCommand(r)}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
lines.push(
|
|
421
|
+
"",
|
|
422
|
+
"2. index each one once now, so the first worker does not wait for the timer",
|
|
423
|
+
"",
|
|
424
|
+
);
|
|
425
|
+
for (const r of repos) lines.push(` ${indexCommand(r)}`);
|
|
426
|
+
lines.push(
|
|
427
|
+
"",
|
|
428
|
+
" Then check what a worker will see. Each root_path below must match a path",
|
|
429
|
+
" above exactly, and the name beside it is what a worker passes as `project`:",
|
|
430
|
+
"",
|
|
431
|
+
` ${INDEXER} cli list_projects`,
|
|
432
|
+
"",
|
|
433
|
+
"3. keep them current",
|
|
434
|
+
"",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const script = reindexScriptPath();
|
|
438
|
+
const { service, timer } = unitPaths(stateDir());
|
|
439
|
+
lines.push(
|
|
440
|
+
` \`graph-setup --write\` 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.",
|
|
444
|
+
"",
|
|
445
|
+
...block(script, reindexScript(p)),
|
|
446
|
+
...block(service, reindexService(p, script)),
|
|
447
|
+
...block(timer, reindexTimer(p)),
|
|
448
|
+
` then install them, which is the only step that needs root:`,
|
|
449
|
+
"",
|
|
450
|
+
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** What `graph-setup --write` did, and the root-only steps it deliberately left. */
|
|
457
|
+
export interface GraphSetupWrite {
|
|
458
|
+
written: string[];
|
|
459
|
+
next: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
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.
|
|
468
|
+
*/
|
|
469
|
+
export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupWrite {
|
|
470
|
+
const script = reindexScriptPath();
|
|
471
|
+
// All three land in the state directory, which this account owns — so the
|
|
472
|
+
// whole command runs unprivileged and there is no sudo path that could
|
|
473
|
+
// 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));
|
|
483
|
+
|
|
484
|
+
const missing = graphRepos(p).filter((r) => !existsSync(r.graphProject));
|
|
485
|
+
const next = [
|
|
486
|
+
"nothing has been installed, enabled or started — this command needs no root",
|
|
487
|
+
`and takes none. The units are staged in ${stateDir()}.`,
|
|
488
|
+
"",
|
|
489
|
+
"to install them, which is the only privileged step:",
|
|
490
|
+
"",
|
|
491
|
+
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
492
|
+
"",
|
|
493
|
+
"then watch one real run before trusting the schedule (it takes minutes per repo):",
|
|
494
|
+
"",
|
|
495
|
+
` sudo systemctl start ${REINDEX_UNIT}.service && systemctl status ${REINDEX_UNIT}.service`,
|
|
496
|
+
...(missing.length === 0
|
|
497
|
+
? []
|
|
498
|
+
: [
|
|
499
|
+
"",
|
|
500
|
+
`first, though: ${missing.length} clone(s) do not exist yet, and the script fails`,
|
|
501
|
+
"loudly rather than skipping them —",
|
|
502
|
+
"",
|
|
503
|
+
...missing.map((r) => ` ${cloneCommand(r)}`),
|
|
504
|
+
]),
|
|
505
|
+
].join("\n");
|
|
506
|
+
|
|
507
|
+
return { written: [script, service, timer], next };
|
|
508
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* worth protecting is on `setup()` below — nothing is written before the confirm.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, isAbsolute } from "node:path";
|
|
14
15
|
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
15
|
-
import { configPath, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
16
|
+
import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
16
17
|
import {
|
|
17
18
|
armConductor,
|
|
18
19
|
formatStatus,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
statusSnapshot,
|
|
23
24
|
type QueuePreview,
|
|
24
25
|
} from "./daemon.ts";
|
|
26
|
+
import { defaultGraphRoot } from "./graph.ts";
|
|
25
27
|
import {
|
|
26
28
|
ORCHESTRATOR_BRIEF_NAME,
|
|
27
29
|
REPORT_SCOPE_CHOICES,
|
|
@@ -287,6 +289,44 @@ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode)
|
|
|
287
289
|
return external ? "external" : "embedded";
|
|
288
290
|
}
|
|
289
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Whether workers get a code graph, and where its clones live.
|
|
294
|
+
*
|
|
295
|
+
* One confirm and at most one prompt, asked after the repos are known because
|
|
296
|
+
* the answer is derived per repo. A declined answer leaves the field off every
|
|
297
|
+
* repo, which is what keeps an existing fleet's briefs byte-identical.
|
|
298
|
+
*
|
|
299
|
+
* The root is validated as absolute here rather than at load time so the
|
|
300
|
+
* operator learns immediately: a relative path would be resolved against
|
|
301
|
+
* whichever cwd happened to read the config, and never against the directory
|
|
302
|
+
* that was indexed.
|
|
303
|
+
*/
|
|
304
|
+
async function askGraphRoot(
|
|
305
|
+
ctx: CommandContext,
|
|
306
|
+
trackerRepo: string,
|
|
307
|
+
repoNames: string[],
|
|
308
|
+
prior: string | undefined,
|
|
309
|
+
): Promise<string | undefined> {
|
|
310
|
+
const wanted = await ctx.ui.confirm(
|
|
311
|
+
"Code-graph discovery",
|
|
312
|
+
"Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
|
|
313
|
+
'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
|
|
314
|
+
"pinned to the default branch purely for indexing — never your own checkout" +
|
|
315
|
+
`${prior === undefined ? "" : `. Currently on, under ${prior}`}.`,
|
|
316
|
+
);
|
|
317
|
+
if (!wanted) return undefined;
|
|
318
|
+
|
|
319
|
+
return await askValid(
|
|
320
|
+
ctx,
|
|
321
|
+
`Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
|
|
322
|
+
prior ?? defaultGraphRoot(trackerRepo),
|
|
323
|
+
(v) =>
|
|
324
|
+
isAbsolute(expandHome(v))
|
|
325
|
+
? undefined
|
|
326
|
+
: `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
290
330
|
/**
|
|
291
331
|
* Whether to render the operator's own brief, and — separately — whether an
|
|
292
332
|
* existing one may be replaced. Two questions on purpose: that file is where a
|
|
@@ -400,6 +440,17 @@ async function collectAnswers(
|
|
|
400
440
|
if (!more) break;
|
|
401
441
|
}
|
|
402
442
|
|
|
443
|
+
// Straight after the repos, because it is a fact about them: one clone per
|
|
444
|
+
// routed repo, under one root. Seeded from whichever prior repo already had
|
|
445
|
+
// one — the wizard writes them as siblings, so any one of them names the root.
|
|
446
|
+
const priorGraph = Object.values(prior?.routing.repos ?? {}).find((r) => r.graphProject !== undefined);
|
|
447
|
+
const graphRoot = await askGraphRoot(
|
|
448
|
+
ctx,
|
|
449
|
+
trackerRepo,
|
|
450
|
+
targetRepos.map((r) => r.name),
|
|
451
|
+
priorGraph?.graphProject === undefined ? undefined : dirname(priorGraph.graphProject),
|
|
452
|
+
);
|
|
453
|
+
|
|
403
454
|
const caps: Partial<Caps> = { ...prior?.caps };
|
|
404
455
|
const tuneCaps = await ctx.ui.confirm(
|
|
405
456
|
"Caps",
|
|
@@ -495,6 +546,7 @@ async function collectAnswers(
|
|
|
495
546
|
};
|
|
496
547
|
if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
|
|
497
548
|
if (workerModel !== undefined) answers.workerModel = workerModel;
|
|
549
|
+
if (graphRoot !== undefined) answers.graphRoot = graphRoot;
|
|
498
550
|
return { ...answers, writeOrchestratorBrief: await askOrchestratorBrief(ctx, answers) };
|
|
499
551
|
}
|
|
500
552
|
|
package/src/setup.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
26
|
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
27
|
+
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
27
28
|
import {
|
|
28
29
|
CONFIG_VERSION,
|
|
29
30
|
DEFAULT_AUTHORITY,
|
|
@@ -83,6 +84,17 @@ export interface SetupAnswers {
|
|
|
83
84
|
* that session to drain, rather than starting a second brain.
|
|
84
85
|
*/
|
|
85
86
|
orchestratorMode: OrchestratorMode;
|
|
87
|
+
/**
|
|
88
|
+
* Parent directory of the index-only clones workers query, or absent when the
|
|
89
|
+
* operator declined code-graph discovery — in which case no repo gets a
|
|
90
|
+
* `graphProject` and every rendered brief is the one this package shipped
|
|
91
|
+
* before graphs existed.
|
|
92
|
+
*
|
|
93
|
+
* One answer for the whole project rather than one per repo: the clones are
|
|
94
|
+
* derived data with no reason to live apart, and a per-repo prompt would ask
|
|
95
|
+
* the same question four times to arrive at four siblings.
|
|
96
|
+
*/
|
|
97
|
+
graphRoot?: string;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** What `gh auth status` says the active token may do. */
|
|
@@ -398,12 +410,19 @@ function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
398
410
|
const dir = stateDir();
|
|
399
411
|
|
|
400
412
|
const repos: Record<string, RepoTarget> = {};
|
|
413
|
+
const graphRoot = a.graphRoot?.trim();
|
|
401
414
|
for (const r of a.targetRepos) {
|
|
402
415
|
repos[r.name] = {
|
|
403
416
|
name: r.name,
|
|
404
417
|
cloneUrl: r.cloneUrl,
|
|
405
418
|
defaultBranch: r.defaultBranch,
|
|
406
419
|
gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
|
|
420
|
+
// One answered root becomes one clone per routed repo. Omitted entirely
|
|
421
|
+
// when unanswered rather than written empty: the key's absence is what
|
|
422
|
+
// makes an existing config's briefs render exactly as they did before.
|
|
423
|
+
...(graphRoot === undefined || graphRoot.length === 0
|
|
424
|
+
? {}
|
|
425
|
+
: { graphProject: graphProjectPath(graphRoot, r.name) }),
|
|
407
426
|
};
|
|
408
427
|
}
|
|
409
428
|
|
|
@@ -694,6 +713,19 @@ export function summarisePlan(
|
|
|
694
713
|
}
|
|
695
714
|
}
|
|
696
715
|
|
|
716
|
+
// Absent entirely when unanswered: a plan for a project with no graph must
|
|
717
|
+
// read exactly as it did before graphs were a thing this wizard could offer.
|
|
718
|
+
const graphed = graphRepos(project);
|
|
719
|
+
if (graphed.length > 0) {
|
|
720
|
+
lines.push("", "code graph workers query these clones instead of grepping:");
|
|
721
|
+
for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`);
|
|
722
|
+
lines.push(
|
|
723
|
+
" conductor's own index-only clones — nothing human edits them, and",
|
|
724
|
+
" nothing here creates them. Run `omp-conductor graph-setup` after",
|
|
725
|
+
" setup: it prints the clone, index and systemd-timer commands.",
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
697
729
|
lines.push("", "caps (effective)");
|
|
698
730
|
for (const [key, value] of Object.entries(effective)) {
|
|
699
731
|
const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
|
package/src/types.ts
CHANGED
|
@@ -46,6 +46,31 @@ export interface RepoTarget {
|
|
|
46
46
|
* subset lets lint errors outside the source dir reach the runners.
|
|
47
47
|
*/
|
|
48
48
|
gates: { cmd: string; cwd: string }[];
|
|
49
|
+
/**
|
|
50
|
+
* Absolute path of the **conductor-owned, index-only clone** of this repo
|
|
51
|
+
* whose code-graph index workers query. Optional: absent means this repo has
|
|
52
|
+
* no graph, and the worker brief says nothing about one.
|
|
53
|
+
*
|
|
54
|
+
* Two things it deliberately is not, and both were paid for:
|
|
55
|
+
*
|
|
56
|
+
* - **Not a worker's worktree.** A code-graph index is keyed by the realpath
|
|
57
|
+
* of the directory it was built from, with no git-worktree awareness, so a
|
|
58
|
+
* run's throwaway `worktrees/<issue>` path is always an empty project. A
|
|
59
|
+
* worker that queried its own cwd would find nothing, conclude there is no
|
|
60
|
+
* graph, and go back to grepping — which is the entire cost this field
|
|
61
|
+
* exists to remove.
|
|
62
|
+
* - **Not a human's checkout.** Refreshing an index means hard-resetting the
|
|
63
|
+
* clone to its default branch. Doing that where somebody works destroys
|
|
64
|
+
* their uncommitted edits; making it safe instead (a fast-forward pull)
|
|
65
|
+
* means the graph reflects whatever feature branch they left checked out.
|
|
66
|
+
* So this names a disposable clone nothing human ever edits, which is what
|
|
67
|
+
* makes the reset both safe and deterministic.
|
|
68
|
+
*
|
|
69
|
+
* `omp-conductor graph-setup` prints how to create and refresh it. Nothing in
|
|
70
|
+
* this package reads an index itself: the daemon only passes this path into
|
|
71
|
+
* the worker brief.
|
|
72
|
+
*/
|
|
73
|
+
graphProject?: string;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/**
|
package/src/worktree.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* for the delta.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
14
|
import type { RepoTarget } from "./types.ts";
|
|
15
15
|
|
|
@@ -96,6 +96,50 @@ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
|
|
|
96
96
|
return code === 0;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Fences the block below so it can be found, replaced, and never duplicated. */
|
|
100
|
+
const EXCLUDE_BEGIN = "# >>> omp-conductor (managed; edit outside this block)";
|
|
101
|
+
const EXCLUDE_END = "# <<< omp-conductor";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Appended to every mirror's `info/exclude`, and so in force in every worktree
|
|
105
|
+
* cut from it. Deliberately the same shapes salvage therefore skips, and
|
|
106
|
+
* for the same reason — a worker's own scaffolding is not the repo's business.
|
|
107
|
+
*
|
|
108
|
+
* Two layers because they catch different moments: this one keeps scratch out
|
|
109
|
+
* of a worker's own `git add -A` and out of its `git status`, which salvage
|
|
110
|
+
* never observes; the salvage list catches whatever a worker created before
|
|
111
|
+
* this landed, or wrote past an ignore with `add -f`.
|
|
112
|
+
*/
|
|
113
|
+
const LOCAL_EXCLUDE = [".scratch*/", ".scratch*", ".env.local", "*.local.sh"];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Adds the managed block to an `info/exclude`, preserving everything else.
|
|
117
|
+
*
|
|
118
|
+
* `info/exclude` is a *local* ignore file, which means it is exactly where an
|
|
119
|
+
* operator or another tool puts patterns they could not put in the tracked
|
|
120
|
+
* `.gitignore` — so overwriting it would silently destroy work that has no
|
|
121
|
+
* other copy. The block is fenced and replaced in place, so re-running this on
|
|
122
|
+
* every dispatch neither duplicates our lines nor disturbs theirs.
|
|
123
|
+
*/
|
|
124
|
+
export function mergeExclude(existing: string): string {
|
|
125
|
+
const begin = existing.indexOf(EXCLUDE_BEGIN);
|
|
126
|
+
const end = existing.indexOf(EXCLUDE_END);
|
|
127
|
+
const theirs =
|
|
128
|
+
begin === -1 || end === -1 || end < begin
|
|
129
|
+
? existing
|
|
130
|
+
: existing.slice(0, begin) + existing.slice(end + EXCLUDE_END.length + 1);
|
|
131
|
+
|
|
132
|
+
// Normalised before recomposing, so the result is byte-identical on every
|
|
133
|
+
// call. Trimming the tail matters twice over: a hand-edited file often has no
|
|
134
|
+
// trailing newline (the first managed line would glue onto their last
|
|
135
|
+
// pattern and match nothing), and without it the blank separator below would
|
|
136
|
+
// accumulate one more newline on each of the thousands of dispatches that
|
|
137
|
+
// call this.
|
|
138
|
+
const body = theirs.replace(/\n+$/, "");
|
|
139
|
+
const head = body === "" ? [] : [body, ""];
|
|
140
|
+
return [...head, EXCLUDE_BEGIN, ...LOCAL_EXCLUDE, EXCLUDE_END, ""].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
/**
|
|
100
144
|
* Rewrites the two `clone --mirror` defaults that are actively dangerous for a
|
|
101
145
|
* cache we cut worktrees from. Applied on every `ensureMirror` so a mirror left
|
|
@@ -122,6 +166,20 @@ async function configureMirror(mirrorPath: string): Promise<void> {
|
|
|
122
166
|
["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC],
|
|
123
167
|
mirrorPath,
|
|
124
168
|
);
|
|
169
|
+
|
|
170
|
+
// A mirror's `info/exclude` is the common git dir for every worktree cut from
|
|
171
|
+
// it, so one write here keeps a worker's own scratch out of `git status` in
|
|
172
|
+
// all of them — without touching the repo's tracked `.gitignore`, which is
|
|
173
|
+
// the operator's file and not ours to edit.
|
|
174
|
+
//
|
|
175
|
+
// Belt and braces with the salvage excludes rather than a replacement for
|
|
176
|
+
// them: this stops scratch reaching a *worker's* own `git add`, which salvage
|
|
177
|
+
// never sees. Merged rather than written, because `info/exclude` is precisely
|
|
178
|
+
// where an operator keeps patterns that cannot go in the tracked file — and
|
|
179
|
+
// this runs on every dispatch, so overwriting would destroy them repeatedly.
|
|
180
|
+
const exclude = join(mirrorPath, "info", "exclude");
|
|
181
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
182
|
+
writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
|
|
125
183
|
}
|
|
126
184
|
|
|
127
185
|
/**
|
|
@@ -343,7 +401,28 @@ export async function salvageWip(
|
|
|
343
401
|
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
344
402
|
|
|
345
403
|
// `-A` on purpose: the losses this exists for were mostly *new* files.
|
|
404
|
+
//
|
|
405
|
+
// Filtering scratch is git's job, not a pathspec's. The mirror's
|
|
406
|
+
// `info/exclude` (see {@link mergeExclude}) is the common git dir for this
|
|
407
|
+
// worktree, and git's own rules then give exactly the semantics needed:
|
|
408
|
+
// untracked ignored files are skipped, while modifications to *tracked*
|
|
409
|
+
// files are staged even when the name matches an ignore. That second half
|
|
410
|
+
// is why an exclude pathspec here was wrong — it matched on filename alone,
|
|
411
|
+
// so a repo legitimately versioning a `bootstrap.local.sh` would have lost
|
|
412
|
+
// a worker's edits to it, salvage destroying the work it exists to save.
|
|
413
|
+
//
|
|
414
|
+
// A literal `:(exclude)<path>` was also an outright bug: git counts it as
|
|
415
|
+
// naming the path, so an already-ignored file made `add` exit 1 and every
|
|
416
|
+
// cap-kill would have reported a salvage *failure*.
|
|
346
417
|
await git(["add", "-A"], worktree);
|
|
418
|
+
|
|
419
|
+
// The dirty check above ran before git applied its ignores, so a tree whose
|
|
420
|
+
// only changes were ignored scratch had work by that test and none by this.
|
|
421
|
+
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
422
|
+
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
423
|
+
if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
|
|
424
|
+
return { kind: "nothing" };
|
|
425
|
+
}
|
|
347
426
|
await git(
|
|
348
427
|
[
|
|
349
428
|
...SALVAGE_COMMIT_CONFIG,
|