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 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 every clone that does not exist yet, the
1082
- one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer`
1083
- pair built from the project's own repos and branches. `--write` stages all three
1084
- in the state directory and prints the two `sudo` lines that install and enable
1085
- them; it never runs `systemctl`.
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.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). |
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": "missing" },
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.17.1",
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
- /** The option applied by `auto-proceed`; required exactly for that outcome. */
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 { ok: false, problem: `conductor_ask ${shape.problem}` };
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. Required when on-timeout is auto-proceed, because the row must record what was auto-applied.",
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
@@ -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 with exactly these seven lines, evidence onlyno narration:
214
-
215
- ```
216
- issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
217
- pr: <url or "none">
218
- head: <40-character head SHA or "none">
219
- state: pushed-green | blocked | failed
220
- gates: <exact commands run and their results>
221
- changed: <the settlement derives this from the PR diff — omit the line>
222
- next: <nothing | the specific decision needed>
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
- The `changed:` line is not yours to write from memory: the settlement replaces
226
- it with the actual file list from the PR's diff. Omit it, or write it wrongly —
227
- the settled report carries the diff's list either way. The narrative in your
228
- report (what you changed and why, above these lines) is the part only you can
229
- write, and it is the part a reviewer reads.
230
-
231
- Never report success you have not observed. "Should pass CI" is not a state, and
232
- `pushed-green` means you watched the checks go green — not that you expect them
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.
@@ -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,
@@ -390,24 +390,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
390
390
  },
391
391
  {
392
392
  name: "watch",
393
- description: "record or list orchestrator-only conditions and carry notes",
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",
@@ -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(`watch ${watch.id} added — ${wake} (no operator answer needed)\n`);
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 list\n`,
139
+ `omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
81
140
  );
82
141
  process.exit(2);
83
142
  } finally {
@@ -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
  };