omp-conductor 0.17.1 → 0.18.0
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/REFERENCE.md +11 -7
- package/package.json +1 -1
- package/schema/config.schema.json +24 -1
- package/src/admission.ts +104 -1
- package/src/ask.ts +39 -3
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +36 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +33 -0
- package/src/config.ts +60 -1
- package/src/daemon.ts +253 -1479
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/failure-class.ts +15 -2
- package/src/fleet.ts +20 -2
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +36 -5
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +104 -12
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +75 -0
- package/src/setup.ts +93 -3
- package/src/status-render.ts +31 -9
- package/src/store.ts +78 -5
- package/src/tracker/github.ts +46 -0
- package/src/types.ts +127 -3
- package/src/verbs/server.ts +58 -2
- package/src/worker.ts +214 -7
- package/src/worktree.ts +115 -8
package/REFERENCE.md
CHANGED
|
@@ -1078,11 +1078,15 @@ omp-conductor setup graph --print # print the plan: clones, index commands,
|
|
|
1078
1078
|
omp-conductor setup graph # run it: clone, install, enable, seed, verify
|
|
1079
1079
|
```
|
|
1080
1080
|
|
|
1081
|
-
`setup graph --print` prints a `git clone` for
|
|
1082
|
-
one-shot index command per repo, and
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1081
|
+
`setup graph --print` prints the plan: the host prerequisites, a `git clone` for
|
|
1082
|
+
every clone that does not exist yet, the one-shot index command per repo, and
|
|
1083
|
+
this project's `cbm-reindex-<project>.sh` script plus `cbm-reindex-<project>.service`
|
|
1084
|
+
+ `cbm-reindex-<project>.timer` unit pair, named for the project so a second
|
|
1085
|
+
project on the same host gets its own refresh instead of silently replacing the
|
|
1086
|
+
first project's (#720). A run stages those three files in the state directory
|
|
1087
|
+
**only after its consent prompt** — declining leaves the staged tree
|
|
1088
|
+
byte-identical — then clones, installs and enables the timer as root, seeds one
|
|
1089
|
+
indexing run, and verifies; it never runs `systemctl` on its own authority.
|
|
1086
1090
|
|
|
1087
1091
|
**Run it as the account the fleet runs as, never under `sudo`** — it refuses if
|
|
1088
1092
|
you try. Everything it derives resolves per-account: the config it loads, the
|
|
@@ -2255,7 +2259,7 @@ omp-conductor help
|
|
|
2255
2259
|
| --- | --- | --- |
|
|
2256
2260
|
| `setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]` | project | The deterministic interview, with styled Clack prompts on an interactive TTY and byte-stable plain output for pipes or `OMP_CONDUCTOR_PLAIN_UI=1`. `--answers` validates a JSON object of stable prompt keys before setup and replaces every prompt; a missing required key exits `1` naming the key and file instead of hanging. `--save-answers` records accepted interactive answers as replayable JSON after a successful run. Bare setup is a full first run, or — when the project already exists — a chooser of which area to amend. Naming an area positionally skips that chooser and amends only that area: `tracker`, `gates`, `caps`, `code-graph`, `authority`, `policy`, `escalation`, `reporting`, `brief`. `host` and `graph` are install subcommands rather than areas and are matched first; anything else exits `2` listing both vocabularies. Every prompt shows its current value as the default, and Enter accepts what you see; `Ctrl-C` at any prompt abandons the run and writes nothing. Setup also **reads your repos to propose answers**: the gates prompt is pre-filled from what CI actually runs, and the brief's `## Project context` and release procedure are drafted from every routing repo and shown for confirmation before anything is written. Each probe is a short session with **no shell, no editor and no verbs** in a throwaway shallow clone, and every answer is a proposal you edit or decline — a probe that cannot clone, cannot reach a model, or answers unusably costs you one warning and the shipped stub. `--no-ai` asks every question with the reading half removed. |
|
|
2257
2261
|
| `setup host [--project NAME]` | host | Re-render and stage the systemd unit, then **run** the install: `install -m 0644` into `/etc/systemd/system`, `daemon-reload`, `enable`, `restart`. Stages the fleet recovery oneshot (`omp-conductor-recover.service`) and its playbook (`/usr/local/sbin/omp-conductor-recover`) alongside, and installs them **before** the fleet units: both fleet units carry `OnFailure=` to the recovery unit, so a crash-looped daemon or herdr session now collects evidence durably, attempts one bounded recovery, and pages tier-2 instead of dying silently (#485). Every command is shown with its exact argv, one confirm covers the batch, and `sudo` asks for your password once before the first step — or is skipped entirely on a fleet that genuinely runs as root. The first failure stops the rest and prints the un-run remainder verbatim so you can finish by hand. Refuses an *escalated* invocation (`sudo`, or `sudo -i`/`su -` detected by the invoking account disagreeing with the fleet's) before writing anything, naming both accounts, because staging derives the unit's `User=`/`HOME=` from whoever ran it. On a non-Linux host the files are still staged and only the `systemctl` steps are refused. |
|
|
2258
|
-
| `setup graph [--no-seed] [--print] [--project NAME]` | project | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex
|
|
2262
|
+
| `setup graph [--no-seed] [--print] [--project NAME]` | project | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); stage this project's `cbm-reindex-<project>.{sh,service,timer}` **after** the confirm — never before, so a declined run leaves the staged tree byte-identical — refusing a stem that already belongs to another project or to a file it did not generate (#720); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex-<project>.timer` as root; then seed one indexing run so the first fetch happens while you watch, and verify with the same probe `status` uses. A repo that does not verify is a failure with the remediation, not a success — staged-but-not-trusted is how you discover months later that no worker read an index. `--no-seed` enables the timer without the seeding run and says plainly the graph is unusable until it first fires; it never skips the prerequisite or clone steps. `--print` changes nothing. Exits `1` when no repo has [`graphProject`](#configuration). |
|
|
2259
2263
|
| `start` | host | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then start the dispatch daemon and wait until it answers `GET /healthz`. When `omp-conductor.service` is installed, systemd is the only start path: even `start --project NAME` restores the shared unit and uses the name only to verify that `/healthz` serves the requested project. A detached daemon is allowed only when the unit is proven absent. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; manager refusal or unprovable ownership is an error rather than a detached fallback. |
|
|
2260
2264
|
| `stop` | fleet | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
|
|
2261
2265
|
| `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | host | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. A daemon serving multiple configured projects makes restart host-wide: `--project` is rejected because draining one queue and restarting the shared process would kill another project's workers. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; only a host proven not to have the installed unit may fall back to the standalone stop/start path. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
|
|
@@ -2355,7 +2359,7 @@ curl -s localhost:8787/healthz
|
|
|
2355
2359
|
"configured": true,
|
|
2356
2360
|
"status": "degraded",
|
|
2357
2361
|
"checkedAt": "2026-08-08T13:00:00.000Z",
|
|
2358
|
-
"prerequisites": { "indexer": "present", "mcpMount": "
|
|
2362
|
+
"prerequisites": { "indexer": "present", "mcpMount": "unconfigured" },
|
|
2359
2363
|
"repos": [
|
|
2360
2364
|
{
|
|
2361
2365
|
"name": "api",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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.",
|
|
@@ -136,7 +136,8 @@
|
|
|
136
136
|
"properties": {
|
|
137
137
|
"inProgress": {},
|
|
138
138
|
"blocked": {},
|
|
139
|
-
"failed": {}
|
|
139
|
+
"failed": {},
|
|
140
|
+
"backlog": {}
|
|
140
141
|
},
|
|
141
142
|
"additionalProperties": false
|
|
142
143
|
},
|
|
@@ -340,6 +341,28 @@
|
|
|
340
341
|
"additionalProperties": false,
|
|
341
342
|
"description": "How `arm` proves a human just approved arming"
|
|
342
343
|
},
|
|
344
|
+
"review": {
|
|
345
|
+
"type": "object",
|
|
346
|
+
"properties": {
|
|
347
|
+
"strictness": {
|
|
348
|
+
"default": "medium",
|
|
349
|
+
"type": "string",
|
|
350
|
+
"enum": [
|
|
351
|
+
"low",
|
|
352
|
+
"medium",
|
|
353
|
+
"high"
|
|
354
|
+
]
|
|
355
|
+
},
|
|
356
|
+
"maxRounds": {
|
|
357
|
+
"default": 3,
|
|
358
|
+
"type": "integer",
|
|
359
|
+
"minimum": 1,
|
|
360
|
+
"maximum": 6
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
"additionalProperties": false,
|
|
364
|
+
"description": "Review strictness and round ceiling for green PRs"
|
|
365
|
+
},
|
|
343
366
|
"authority": {
|
|
344
367
|
"type": "object",
|
|
345
368
|
"properties": {
|
package/src/admission.ts
CHANGED
|
@@ -32,7 +32,8 @@ import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.t
|
|
|
32
32
|
import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
|
|
33
33
|
import { repoSlugFor } from "./gitops.ts";
|
|
34
34
|
import { branchName, type Routed } from "./routing.ts";
|
|
35
|
-
import { parseDependsOn } from "./depends-on.ts";
|
|
35
|
+
import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
|
|
36
|
+
import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
|
|
36
37
|
|
|
37
38
|
/** Fleet-wide escalations still need an issue number in the payload; 0 is the
|
|
38
39
|
* sentinel that reads as "no issue" in every renderer. */
|
|
@@ -64,6 +65,16 @@ export interface AdmissionDeps {
|
|
|
64
65
|
* prerequisite closed too — the daemon always wires it.
|
|
65
66
|
*/
|
|
66
67
|
probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
|
|
68
|
+
/**
|
|
69
|
+
* Reads one issue's BODY in a repository the admission tracker is NOT bound
|
|
70
|
+
* to — the cross-repo half of the dependency-graph cycle pass (#421). The
|
|
71
|
+
* tracker bound to `project.tracker.repo` already answers same-repo bodies
|
|
72
|
+
* via {@link Tracker.issueBody}; a reachable prerequisite body in a routed
|
|
73
|
+
* repo goes through this. Undefined means "could not tell"; unset, a routed
|
|
74
|
+
* reachable body fails that branch closed (no false cycle), exactly like
|
|
75
|
+
* {@link Tracker.issueBody}. The daemon always wires it.
|
|
76
|
+
*/
|
|
77
|
+
probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
|
|
67
78
|
}
|
|
68
79
|
/** `stops` are the operational ends that each require one resume. */
|
|
69
80
|
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
@@ -259,6 +270,18 @@ export function declaredLane(body: string): string[] {
|
|
|
259
270
|
return laneDeclaration(body)?.files ?? [];
|
|
260
271
|
}
|
|
261
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The file lane as one line, for surfaces where a human asserts the claim
|
|
275
|
+
* (#724): the parsed file list exactly as admission will enforce it, or the
|
|
276
|
+
* explicit fail-open note when nothing parsed. Verbatim parser output only —
|
|
277
|
+
* a paraphrase would hide the very over-parse (#720) or under-parse (#684)
|
|
278
|
+
* this echo exists to surface.
|
|
279
|
+
*/
|
|
280
|
+
export function laneEcho(lane: LaneDeclaration | undefined): string {
|
|
281
|
+
if (lane === undefined) return "no lane declared (fail open)";
|
|
282
|
+
return lane.files.join(", ");
|
|
283
|
+
}
|
|
284
|
+
|
|
262
285
|
/** A plausible relative path: it has a `.` extension or a directory separator. */
|
|
263
286
|
function isPathLike(token: string): boolean {
|
|
264
287
|
return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
|
|
@@ -469,8 +492,79 @@ export async function admitCandidates(
|
|
|
469
492
|
}
|
|
470
493
|
|
|
471
494
|
const admitted: Admission[] = [];
|
|
495
|
+
|
|
496
|
+
// Phase A — dependency-graph cycle pass (#421). Built ONCE per pass, before
|
|
497
|
+
// any per-candidate open-prerequisite hold, so a candidate sitting on a cycle
|
|
498
|
+
// is held as `dependency-cycle` instead of having an open first edge mask the
|
|
499
|
+
// group. Readers follow the same tracker/probe seams as the per-candidate
|
|
500
|
+
// interlock: same-repo refs via `tracker`, routed refs via `probeIssueIn` /
|
|
501
|
+
// `probeBodyIn`. Traversal is bounded to the tracker/routed repos and every
|
|
502
|
+
// node is read at most once; a tracker failure fails closed (no edge) rather
|
|
503
|
+
// than ever synthesising a cycle.
|
|
504
|
+
const readDepNode: DependencyNodeReader = async (repo, issue) => {
|
|
505
|
+
const snap = await (async () => {
|
|
506
|
+
try {
|
|
507
|
+
if (repo === project.tracker.repo) return await tracker.issueSnapshot(issue);
|
|
508
|
+
return d.probeIssueIn === undefined ? undefined : await d.probeIssueIn(repo, issue);
|
|
509
|
+
} catch {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
})();
|
|
513
|
+
if (snap === undefined) return undefined;
|
|
514
|
+
if (snap.state === "closed") return { state: "closed", body: "" };
|
|
515
|
+
const body = await (async () => {
|
|
516
|
+
try {
|
|
517
|
+
if (repo === project.tracker.repo) return await tracker.issueBody(issue);
|
|
518
|
+
return d.probeBodyIn === undefined ? undefined : await d.probeBodyIn(repo, issue);
|
|
519
|
+
} catch {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
})();
|
|
523
|
+
return body === undefined ? undefined : { state: "open", body };
|
|
524
|
+
};
|
|
525
|
+
const graph = await buildDependencyCycles(
|
|
526
|
+
routed.map((r) => ({ repo: project.tracker.repo, issue: r.issue.number, body: r.issue.body })),
|
|
527
|
+
project.tracker.repo,
|
|
528
|
+
(ownerRepo) => crossRepoTarget(project, ownerRepo),
|
|
529
|
+
readDepNode,
|
|
530
|
+
);
|
|
531
|
+
// candidate issue -> the cycle path that holds it, for status/digest.
|
|
532
|
+
const memberCycle = new Map<number, string>();
|
|
533
|
+
for (const cycle of graph.cycles) {
|
|
534
|
+
for (const member of cycle.members) {
|
|
535
|
+
const [repo, issueStr] = member.split("#") as [string, string];
|
|
536
|
+
if (repo !== project.tracker.repo) continue;
|
|
537
|
+
const n = Number(issueStr);
|
|
538
|
+
// Prefer the cycle anchored at this member (its "own" cycle) when a
|
|
539
|
+
// candidate sits on more than one.
|
|
540
|
+
if (!memberCycle.has(n) || cycle.anchorIssue === n) memberCycle.set(n, cycle.display);
|
|
541
|
+
}
|
|
542
|
+
// One material event per stable cycle identity: the escalation ledger
|
|
543
|
+
// dedupes on project/issue/tier/summary, and the summary carries the
|
|
544
|
+
// canonical path, so a stable cycle pages once and a changed cycle emits a
|
|
545
|
+
// new event (the rotated/queue-ordered spelling is canonicalised away).
|
|
546
|
+
await safeEscalate(d, {
|
|
547
|
+
tier: 1,
|
|
548
|
+
project: project.name,
|
|
549
|
+
issue: cycle.anchorIssue,
|
|
550
|
+
summary: `Dependency cycle detected: ${cycle.display}`,
|
|
551
|
+
detail: [
|
|
552
|
+
"None of the issues on this cycle can be dispatched until one edge is broken.",
|
|
553
|
+
`Cycle: ${cycle.display}`,
|
|
554
|
+
"Break it by closing or reordering a `Depends-on:` reference, and the affected",
|
|
555
|
+
"candidates become claimable again on the next tick.",
|
|
556
|
+
].join("\n"),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
472
560
|
for (const r of routed) {
|
|
473
561
|
const issue = r.issue.number;
|
|
562
|
+
const cyclePath = memberCycle.get(issue);
|
|
563
|
+
if (cyclePath !== undefined) {
|
|
564
|
+
hold(issue, "dependency-cycle", cyclePath);
|
|
565
|
+
log(`#${issue} held (dependency-cycle): ${cyclePath}`);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
474
568
|
if (admitted.length >= slots) {
|
|
475
569
|
hold(issue, "capacity");
|
|
476
570
|
continue;
|
|
@@ -761,6 +855,15 @@ export async function admitCandidates(
|
|
|
761
855
|
log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
|
|
762
856
|
continue;
|
|
763
857
|
}
|
|
858
|
+
// Park beats queue here too (#734): the candidate list was fetched from
|
|
859
|
+
// the search index before the operator parked the issue, so the fresh
|
|
860
|
+
// snapshot is the last gate. A park never touches a live run — it only
|
|
861
|
+
// stops a claim.
|
|
862
|
+
if (snapshot.labels.includes(project.stateLabels.backlog)) {
|
|
863
|
+
hold(issue, "issue-parked");
|
|
864
|
+
log(`#${issue} skipped: park label ${project.stateLabels.backlog} was applied since listing`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
764
867
|
|
|
765
868
|
// The Depends-on interlock (#419/#420): a candidate declares the issues it
|
|
766
869
|
// must not be dispatched before — same-repo (`#123`) or cross-repo
|
package/src/ask.ts
CHANGED
|
@@ -93,7 +93,11 @@ export interface AskRequest {
|
|
|
93
93
|
timeoutSeconds?: number;
|
|
94
94
|
/** What is waiting on the answer, for the decision row and the digest. */
|
|
95
95
|
blocks?: string;
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The option applied by `auto-proceed`; required exactly for that outcome.
|
|
98
|
+
* When {@link AskRequest.options} are supplied it must name one of their
|
|
99
|
+
* labels — the label as delivered, never an index.
|
|
100
|
+
*/
|
|
97
101
|
recommended?: string;
|
|
98
102
|
/** The choices shown to the operator, with {@link AskRequest.recommended} named. */
|
|
99
103
|
options?: AskOption[];
|
|
@@ -115,6 +119,13 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
|
|
|
115
119
|
* {@link MAX_QUESTION_LINES} / {@link MAX_QUESTION_CHARACTERS}, and refuses
|
|
116
120
|
* rather than truncating: a truncated question reads as complete and has lost
|
|
117
121
|
* its options, which is strictly worse than a rejection the caller must fix.
|
|
122
|
+
*
|
|
123
|
+
* The refusal is terminal for the call (#741): nothing is recorded or
|
|
124
|
+
* delivered, and the caller must not resubmit the trimmed question in the same
|
|
125
|
+
* turn — the rationale belongs in the issue or the event ledger, and a
|
|
126
|
+
* decision that still needs a human answer is raised as a fresh ask on a later
|
|
127
|
+
* tick. A re-issued ask therefore always starts from the accepted arguments of
|
|
128
|
+
* a new call, never from a rewrite of a refused one.
|
|
118
129
|
*/
|
|
119
130
|
export function validateQuestionShape(question: string): QuestionShape {
|
|
120
131
|
const lines = question.split("\n").length;
|
|
@@ -151,7 +162,13 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
151
162
|
}
|
|
152
163
|
const shape = validateQuestionShape(question.trim());
|
|
153
164
|
if (!shape.ok) {
|
|
154
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
problem:
|
|
168
|
+
`conductor_ask ${shape.problem} Do not re-issue the trimmed question in this turn: if the ` +
|
|
169
|
+
"decision still needs a human answer, say so in the report and raise it as a fresh ask in a " +
|
|
170
|
+
"later tick.",
|
|
171
|
+
};
|
|
155
172
|
}
|
|
156
173
|
|
|
157
174
|
const onTimeoutRaw = input["on-timeout"];
|
|
@@ -225,6 +242,25 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
225
242
|
options = parsedOptions;
|
|
226
243
|
}
|
|
227
244
|
|
|
245
|
+
// A recommendation must name a supplied option. `recommended` is the option
|
|
246
|
+
// label exactly as the operator reads it — never an index (that is
|
|
247
|
+
// telegram_ask's convention, not this tool's) and never free text when a
|
|
248
|
+
// menu exists. Refused at parse time so the incoherent ask leaves no trace:
|
|
249
|
+
// on `auto-proceed` its text becomes the recorded auto-applied answer, and a
|
|
250
|
+
// value that named no option would resolve a decision row to something that
|
|
251
|
+
// was never a choice (#740).
|
|
252
|
+
if (options !== undefined && recommended !== undefined) {
|
|
253
|
+
const labels = options.map((option) => option.label);
|
|
254
|
+
if (!labels.includes(recommended)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
problem:
|
|
258
|
+
`conductor_ask recommended "${recommended}" names no option — it must be one of: ${labels.join(", ")}. ` +
|
|
259
|
+
"It is the option label exactly as delivered to the operator, not an index",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
228
264
|
const categoryRaw = input["category"];
|
|
229
265
|
if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) {
|
|
230
266
|
return {
|
|
@@ -274,7 +310,7 @@ export function askParameterSchema(): Record<string, unknown> {
|
|
|
274
310
|
recommended: {
|
|
275
311
|
type: "string",
|
|
276
312
|
description:
|
|
277
|
-
"The option applied on auto-proceed
|
|
313
|
+
"The option applied on auto-proceed; required when on-timeout is auto-proceed because the row must record what was auto-applied. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
|
|
278
314
|
},
|
|
279
315
|
blocks: {
|
|
280
316
|
type: "string",
|
package/src/backups.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
|
|
20
20
|
/** A timestamp stem safe to embed in a file name. */
|
|
21
|
-
export function backupTimestamp(): string {
|
|
22
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
21
|
+
export function backupTimestamp(at: number = Date.now()): string {
|
|
22
|
+
return new Date(at).toISOString().replace(/[:.]/g, "-");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -66,6 +66,7 @@ For each one, pick exactly one of three outcomes:
|
|
|
66
66
|
question that unblocks it. Do not guess: a wrong answer costs a worker's whole
|
|
67
67
|
budget and lands a wrong PR, while an unanswered question costs a delay.
|
|
68
68
|
{{MERGE_DUTY}}
|
|
69
|
+
{{REVIEW_DUTY}}
|
|
69
70
|
|
|
70
71
|
**Then check for orphans.** A worker is a process, and processes die: a daemon
|
|
71
72
|
restart, a host reboot, a kill. The `agent:in-progress` label survives that death
|
package/src/briefs/worker.md
CHANGED
|
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
|
|
|
47
47
|
|
|
48
48
|
{{ACCEPTANCE_CRITERIA}}
|
|
49
49
|
|
|
50
|
-
{{ISSUE_COMMENTS}}## How to work
|
|
50
|
+
{{ISSUE_COMMENTS}}{{FILE_LANE}}## How to work
|
|
51
51
|
|
|
52
52
|
1. **Understand before editing — and ask the graph before you grep.** Your turns
|
|
53
53
|
are mostly spent finding code, not writing it, and running out of turns
|
|
@@ -76,6 +76,18 @@ files are canonical; your priors are not.
|
|
|
76
76
|
genuinely cannot be done small, stop and escalate rather than ballooning.
|
|
77
77
|
5. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
|
|
78
78
|
assertion, or special-case an input to make a check pass.
|
|
79
|
+
6. **Prefer the structured edit tool for file changes, and the structured
|
|
80
|
+
search tool over shelling out.** A structured edit is one call: anchored and
|
|
81
|
+
verified, and a stale anchor fails loudly instead of silently editing the
|
|
82
|
+
wrong line. The shell equivalent is three or four — compose the script,
|
|
83
|
+
escape it correctly, run it, then read the file back to confirm it did what
|
|
84
|
+
was intended — and a mis-escaped `sed -i` pattern silently edits nothing or
|
|
85
|
+
the wrong line. So do not hand-roll edits through `sed -i`, `python3`
|
|
86
|
+
heredocs, `node -e` or shell redirection except where no structured tool can
|
|
87
|
+
express the change (a binary file, a generated artefact). The same holds for
|
|
88
|
+
finding code: use the structured search tool rather than shelling out to
|
|
89
|
+
grep, for the same reason the graph line exists — it is cheaper per call and
|
|
90
|
+
its output is already scoped.
|
|
79
91
|
|
|
80
92
|
## Tests — read this carefully
|
|
81
93
|
|
|
@@ -210,24 +222,29 @@ Escalating is a successful outcome. Guessing is not.
|
|
|
210
222
|
|
|
211
223
|
## Your final report
|
|
212
224
|
|
|
213
|
-
End
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
End your run by yielding the settlement through the `yield` tool — one
|
|
226
|
+
structured call, and the schema is the contract: the harness showed it to you
|
|
227
|
+
at session start. The expected shape:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"status": "green",
|
|
232
|
+
"prUrl": "https://github.com/.../pull/N",
|
|
233
|
+
"headSha": "<the 40-char head you watched go green>",
|
|
234
|
+
"summary": "What you changed and why — the narrative a reviewer reads.",
|
|
235
|
+
"proof": ["bun test omp/src/worker.test.ts"]
|
|
236
|
+
}
|
|
223
237
|
```
|
|
224
238
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
Call it as `yield({ result: { data: <the object> } })` with no `type` — the
|
|
240
|
+
usual terminal yield. `status: "green"` means you pushed and **watched the
|
|
241
|
+
checks go green**, and it requires both `prUrl` and `headSha`. Use `blocked`
|
|
242
|
+
(with `blockers`) when a decision or credential is missing, `failed` when the
|
|
243
|
+
run could not complete. The dispatcher renders your yielded settlement into
|
|
244
|
+
the stored report, so `summary` is what a reviewer reads and `proof` is the
|
|
245
|
+
evidence; the `changed:` file list is derived from the PR's own diff, never
|
|
246
|
+
written by you.
|
|
247
|
+
|
|
248
|
+
Never report success you have not observed. "Should pass CI" is not a state,
|
|
249
|
+
and `green` means you watched the checks go green — not that you expect them
|
|
233
250
|
to.
|
package/src/command-help.ts
CHANGED
|
@@ -168,9 +168,16 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
168
168
|
condition the daemon checks for you; a met watch wakes the next tick
|
|
169
169
|
with its note, exactly as a met question does, but it is listed under
|
|
170
170
|
its own heading and never under "Open operator decisions", and it has
|
|
171
|
-
no seven-day expiry. \`watch list\` shows open watches
|
|
171
|
+
no seven-day expiry. \`watch list\` shows open watches; \`watch
|
|
172
|
+
withdraw <id>\` ends one with a recorded reason — the verb that
|
|
173
|
+
creates a watch is the verb that ends it (watches are decision rows,
|
|
174
|
+
so \`decision withdraw <id>\` also works). A watch whose PR condition
|
|
175
|
+
can no longer be observed — the PR merged or closed before the
|
|
176
|
+
condition was seen — is withdrawn by the daemon itself with the
|
|
177
|
+
reason recorded.
|
|
172
178
|
watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
|
|
173
179
|
watch list
|
|
180
|
+
watch withdraw <id> [--reason TEXT]
|
|
174
181
|
intake keep a raw idea durably before it becomes anything: record it now
|
|
175
182
|
with \`omp-conductor intake "<text>"\`, list what is still pending,
|
|
176
183
|
dismiss what turned out to be nothing. Backed by the sqlite store,
|
package/src/command-manifest.ts
CHANGED
|
@@ -390,24 +390,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
390
390
|
},
|
|
391
391
|
{
|
|
392
392
|
name: "watch",
|
|
393
|
-
description: "record or
|
|
393
|
+
description: "record, list, or withdraw orchestrator-only conditions and carry notes",
|
|
394
394
|
scope: "project",
|
|
395
395
|
usage: [
|
|
396
396
|
"watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
|
|
397
397
|
"watch list [--project NAME] [--json]",
|
|
398
|
+
"watch withdraw <id> [--reason TEXT] [--project NAME]",
|
|
398
399
|
],
|
|
399
400
|
subcommands: [
|
|
400
401
|
{ name: "add", description: "record a watch" },
|
|
401
402
|
{ name: "list", description: "list open watches" },
|
|
403
|
+
{ name: "withdraw", description: "withdraw an obsolete watch" },
|
|
402
404
|
],
|
|
403
405
|
flags: [
|
|
404
406
|
value("--note", "note carried when the watch resolves"),
|
|
405
407
|
value("--blocks", "what the watch blocks"),
|
|
406
408
|
value("--resolves-when", "automatic resolution condition"),
|
|
409
|
+
value("--reason", "withdrawal reason"),
|
|
407
410
|
toggle("--json", "print watch list as stable JSON"),
|
|
408
411
|
project(),
|
|
409
412
|
],
|
|
410
|
-
positionals: [{ name: "action" }],
|
|
413
|
+
positionals: [{ name: "action" }, { name: "id" }],
|
|
411
414
|
},
|
|
412
415
|
{
|
|
413
416
|
name: "intake",
|
package/src/commands/watch.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* next tick should read, neither of which ever needs an operator answer. It is
|
|
11
11
|
* distinguished durably by its `kind`, never by whether it carries a
|
|
12
12
|
* condition — a real question may carry one too.
|
|
13
|
+
*
|
|
14
|
+
* Watches are decision rows with `kind === "watch"`, so `watch withdraw`
|
|
15
|
+
* delegates to the same store resolution `decision withdraw` uses rather than
|
|
16
|
+
* inventing a second mechanism: both verbs write the same durable terminal
|
|
17
|
+
* state with a recorded reason, and a watch id remains acceptable to `decision
|
|
18
|
+
* withdraw` for anyone who learned that vocabulary first (#664).
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { CommandContext } from "./context.ts";
|
|
@@ -17,8 +23,25 @@ import { findProject, loadConfig } from "../config.ts";
|
|
|
17
23
|
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
18
24
|
import { dbPath, openStore } from "../store.ts";
|
|
19
25
|
|
|
26
|
+
const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
|
|
27
|
+
|
|
28
|
+
usage:
|
|
29
|
+
omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
30
|
+
omp-conductor watch list [--project NAME] [--json]
|
|
31
|
+
omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
|
|
32
|
+
|
|
33
|
+
add records a row the daemon checks for you and the next tick reads, with no
|
|
34
|
+
operator answer needed. list shows open watches, oldest first. withdraw ends
|
|
35
|
+
one with a recorded reason — the verb that creates a watch is the verb that
|
|
36
|
+
ends it. A watch whose PR condition can no longer be observed (the PR merged
|
|
37
|
+
or closed first) is withdrawn by the daemon itself.`;
|
|
38
|
+
|
|
20
39
|
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
21
40
|
const sub = ctx.argv[1];
|
|
41
|
+
if (sub === "--help" || sub === "-h") {
|
|
42
|
+
process.stdout.write(WATCH_USAGE);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
22
45
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
46
|
const store = openStore(dbPath());
|
|
24
47
|
try {
|
|
@@ -45,7 +68,9 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
45
68
|
at: Date.now(),
|
|
46
69
|
});
|
|
47
70
|
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
48
|
-
process.stdout.write(
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
|
|
73
|
+
);
|
|
49
74
|
return;
|
|
50
75
|
}
|
|
51
76
|
|
|
@@ -70,14 +95,48 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
70
95
|
for (const watch of watches) {
|
|
71
96
|
process.stdout.write(
|
|
72
97
|
`${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
|
|
73
|
-
`condition:${watch.condition ?? "-"} ${watch.note}\n`,
|
|
98
|
+
`condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id})\n`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (sub === "withdraw") {
|
|
105
|
+
const id = ctx.argv[2];
|
|
106
|
+
if (id === undefined || id.startsWith("--")) {
|
|
107
|
+
process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
const row = store.decision(id);
|
|
111
|
+
if (row !== undefined && row.kind !== "watch") {
|
|
112
|
+
// The same underlying row, but a question answers to a human: the
|
|
113
|
+
// watch verb must not silently close an operator decision.
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`omp-conductor: watch withdraw targets watches — ${id} is an operator decision; use decision withdraw\n`,
|
|
116
|
+
);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
const reason = ctx.flag("reason")?.trim();
|
|
120
|
+
const ok = store.resolveDecision(
|
|
121
|
+
id,
|
|
122
|
+
"withdrawn",
|
|
123
|
+
reason === undefined || reason.length === 0 ? "withdrawn" : reason,
|
|
124
|
+
Date.now(),
|
|
125
|
+
);
|
|
126
|
+
if (!ok) {
|
|
127
|
+
// A watch that is not open is a different mistake from an id that
|
|
128
|
+
// never existed, and the operator can only act on one of them.
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`omp-conductor: no open watch ${id} for ${project.name} — it was already withdrawn, or the id is wrong\n`,
|
|
74
131
|
);
|
|
132
|
+
process.exit(1);
|
|
75
133
|
}
|
|
134
|
+
process.stdout.write(`watch ${id} withdrawn\n`);
|
|
76
135
|
return;
|
|
77
136
|
}
|
|
78
137
|
|
|
79
138
|
process.stderr.write(
|
|
80
|
-
`omp-conductor: unknown watch subcommand "${sub}" — expected add or
|
|
139
|
+
`omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
|
|
81
140
|
);
|
|
82
141
|
process.exit(2);
|
|
83
142
|
} finally {
|
package/src/config-schema.ts
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
DEFAULT_AUTHORITY,
|
|
32
32
|
DEFAULT_CAPS,
|
|
33
33
|
DEFAULT_PROJECT_POLICY,
|
|
34
|
+
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
35
|
+
DEFAULT_REVIEW_STRICTNESS,
|
|
34
36
|
DRAFT_POLICIES,
|
|
35
37
|
INTERRUPT_CATEGORIES,
|
|
36
38
|
LEGACY_RELEASE_POLICIES,
|
|
@@ -39,6 +41,9 @@ import {
|
|
|
39
41
|
RELEASE_REQUIREMENTS,
|
|
40
42
|
RELEASE_SHAPES,
|
|
41
43
|
REPORT_SCOPES,
|
|
44
|
+
REVIEW_MAX_ROUNDS_MAX,
|
|
45
|
+
REVIEW_MAX_ROUNDS_MIN,
|
|
46
|
+
REVIEW_STRICTNESS,
|
|
42
47
|
WEEKDAYS,
|
|
43
48
|
DIGEST_CADENCES,
|
|
44
49
|
} from "./types.ts";
|
|
@@ -65,6 +70,7 @@ const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
|
|
|
65
70
|
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
66
71
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
67
72
|
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
73
|
+
const REVIEW_STRICTNESS_LIST = quoteList(REVIEW_STRICTNESS);
|
|
68
74
|
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
// Closed vocabularies — every one built from the exported `as const` array in
|
|
@@ -83,6 +89,7 @@ const releaseRequirementEnum = z.enum([...RELEASE_REQUIREMENTS]);
|
|
|
83
89
|
const releaseShapeEnum = z.enum([...RELEASE_SHAPES]);
|
|
84
90
|
const orchestratorModeEnum = z.enum([...ORCHESTRATOR_MODES]);
|
|
85
91
|
const releasePolicyLegacyEnum = z.enum([...LEGACY_RELEASE_POLICIES]);
|
|
92
|
+
const reviewStrictnessEnum = z.enum([...REVIEW_STRICTNESS]);
|
|
86
93
|
|
|
87
94
|
/** The 24-hour `HH:MM` shape `digest.at` / `availability.start/end` take. */
|
|
88
95
|
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
@@ -256,6 +263,27 @@ const armSchema = z
|
|
|
256
263
|
.strict()
|
|
257
264
|
.describe("How `arm` proves a human just approved arming");
|
|
258
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The per-project review policy (#678): how strictly green PRs are reviewed
|
|
268
|
+
* and returned, and the hard ceiling on review rounds per PR lifecycle. Absent
|
|
269
|
+
* or a legacy config without the key loads as `medium` with
|
|
270
|
+
* {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds — the recommended default for a new
|
|
271
|
+
* project, materialised deterministically for every existing one, with the
|
|
272
|
+
* schema bounds and the runtime defaults read from the same constants.
|
|
273
|
+
*/
|
|
274
|
+
const reviewSchema = z
|
|
275
|
+
.object({
|
|
276
|
+
strictness: reviewStrictnessEnum.default(DEFAULT_REVIEW_STRICTNESS),
|
|
277
|
+
maxRounds: z
|
|
278
|
+
.number()
|
|
279
|
+
.int()
|
|
280
|
+
.min(REVIEW_MAX_ROUNDS_MIN)
|
|
281
|
+
.max(REVIEW_MAX_ROUNDS_MAX)
|
|
282
|
+
.default(DEFAULT_REVIEW_MAX_ROUNDS),
|
|
283
|
+
})
|
|
284
|
+
.strict()
|
|
285
|
+
.describe("Review strictness and round ceiling for green PRs");
|
|
286
|
+
|
|
259
287
|
const releasePolicySchema = z.union([
|
|
260
288
|
releasePolicyLegacyEnum,
|
|
261
289
|
z.record(z.string(), authorityHolderEnum),
|
|
@@ -304,6 +332,7 @@ const stateLabelsSchema = z
|
|
|
304
332
|
inProgress: z.unknown(),
|
|
305
333
|
blocked: z.unknown(),
|
|
306
334
|
failed: z.unknown(),
|
|
335
|
+
backlog: z.unknown(),
|
|
307
336
|
})
|
|
308
337
|
.partial();
|
|
309
338
|
|
|
@@ -350,6 +379,9 @@ const projectSchema = z
|
|
|
350
379
|
// How `arm` proves a human approved arming (#613); absent loads as
|
|
351
380
|
// `challenge`, preserving today's authenticated round-trip.
|
|
352
381
|
arm: armSchema.optional(),
|
|
382
|
+
// Review strictness and the round ceiling (#678); absent loads as the
|
|
383
|
+
// documented migration default.
|
|
384
|
+
review: reviewSchema.optional(),
|
|
353
385
|
authority: authoritySchema.optional(),
|
|
354
386
|
releasePolicy: releasePolicySchema.optional(),
|
|
355
387
|
policy: projectPolicySchema.optional(),
|
|
@@ -448,4 +480,5 @@ export {
|
|
|
448
480
|
RELEASE_SHAPE_LIST,
|
|
449
481
|
ORCHESTRATOR_MODE_LIST,
|
|
450
482
|
LEGACY_RELEASE_POLICY_LIST,
|
|
483
|
+
REVIEW_STRICTNESS_LIST,
|
|
451
484
|
};
|