omp-conductor 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -35,6 +35,7 @@ const STATE_TEXT = {
35
35
  ok: "daemon up",
36
36
  stopped: "daemon down",
37
37
  unreachable: "daemon down — not answering /healthz",
38
+ unresponsive: "daemon up — healthz timed out",
38
39
  "other-project": "daemon down — serves another project",
39
40
  };
40
41
 
@@ -239,7 +240,9 @@ async function openProject(name) {
239
240
  projectDaemon.textContent = daemonLine(row);
240
241
  projectDaemon.classList.toggle(
241
242
  "state-degraded",
242
- row.daemon.state === "unreachable" || row.daemon.state === "other-project",
243
+ row.daemon.state === "unreachable" ||
244
+ row.daemon.state === "unresponsive" ||
245
+ row.daemon.state === "other-project",
243
246
  );
244
247
  }
245
248
  await refreshProject("board");
@@ -114,7 +114,7 @@ export interface DashboardProjectView {
114
114
  repo: string;
115
115
  daemon: {
116
116
  /** The shared verdict every viewer uses (#379). */
117
- state: "ok" | "stopped" | "unreachable" | "other-project";
117
+ state: "ok" | "stopped" | "unreachable" | "unresponsive" | "other-project";
118
118
  /** The living daemon's port when a record exists; null when there is none. */
119
119
  port: number | null;
120
120
  /** The raw `/healthz` body, parsed, when this project's daemon answered it. */
@@ -210,7 +210,10 @@ export async function fleetOverview(): Promise<FleetOverviewRow[]> {
210
210
  liveWorkers: snap.liveWorkers,
211
211
  spendTodayUsd: snap.spendTodayUsd,
212
212
  baseHealth: snap.baseHealth,
213
- degraded: view.daemon.state === "unreachable" || view.daemon.state === "other-project",
213
+ degraded:
214
+ view.daemon.state === "unreachable" ||
215
+ view.daemon.state === "unresponsive" ||
216
+ view.daemon.state === "other-project",
214
217
  };
215
218
  });
216
219
  }
package/src/decisions.ts CHANGED
@@ -12,13 +12,14 @@
12
12
  * open row is rendered into a tick prompt.
13
13
  */
14
14
 
15
- import type { DecisionRecord, Store, Tracker } from "./types.ts";
15
+ import type { DecisionRecord, RunRecord, Store, Tracker } from "./types.ts";
16
+ import { shellQuote } from "./shell.ts";
16
17
  import { fetchRateLimit, RATE_LIMIT_COOLDOWN_MS } from "./tracker/github.ts";
17
18
 
18
19
  /**
19
20
  * A precondition whose truth this package can check on its own.
20
21
  *
21
- * Exactly six kinds, deliberately. Each one is a question the tracker or npm
22
+ * Exactly seven kinds, deliberately. Each one is a question the tracker or npm
22
23
  * already answers, so the row moves from "parked" to "act on this" without a
23
24
  * human re-reading it. Anything richer — a label appearing, a workflow going
24
25
  * green — is a follow-on issue rather than a grammar nobody validated.
@@ -28,6 +29,7 @@ export type DecisionCondition =
28
29
  | { kind: "issue-closed"; issue: number }
29
30
  | { kind: "npm-version"; spec: string }
30
31
  | { kind: "pr-checks-green"; url: string }
32
+ | { kind: "pr-review-ready"; url: string }
31
33
  | { kind: "pr-mergeable"; url: string }
32
34
  | { kind: "rate-limit-reset" };
33
35
 
@@ -42,6 +44,72 @@ const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
42
44
  */
43
45
  const GREEN_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
44
46
 
47
+ /**
48
+ * The run states a review revision may start from (#795) — the single
49
+ * definition shared by `conductor_pr_review` and the `pr-review-ready` watch,
50
+ * so the verb's gate and the condition can never name different sets.
51
+ *
52
+ * A revision round resumes the exact run whose row owns the PR, so the
53
+ * revisable states are exactly the terminal runs that pushed one: a settled
54
+ * `pushed-green` row, or a `failed` / `killed` row — a run that capped or
55
+ * failed *after* pushing a green PR. The PR is the durable artefact, the
56
+ * exact-head green verification is the gate on "green at the reviewed SHA",
57
+ * and a terminal row proves no worker is in flight, so findings are returned
58
+ * without the close-PR → unblock → continuation dance.
59
+ *
60
+ * Closed on purpose: a live row (`running` / `claimed`) is already doing its
61
+ * own work, a `pushed-pending` PR is not green yet, and a `blocked` /
62
+ * `orphaned` / `stopped` / `merged` row is not work returned for revision.
63
+ */
64
+ export const REVISABLE_RUN_STATES: Record<string, true> = {
65
+ "pushed-green": true,
66
+ failed: true,
67
+ killed: true,
68
+ };
69
+
70
+ /**
71
+ * How far back a PR's project-owned run may be looked up. PR verbs and the
72
+ * `pr-review-ready` watch share this bound: a pull request older than it is
73
+ * not one an orchestrator is mid-flight on.
74
+ */
75
+ export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
76
+
77
+ /**
78
+ * One predicate for "a run-owned pull request can be returned for revision",
79
+ * read by both `conductor_pr_review` and the `pr-review-ready` watch so they
80
+ * cannot drift.
81
+ *
82
+ * Ownership is the newest attempt of this project that recorded the PR within
83
+ * the recent-history window (`runsForProjectPr`), never the issue's newest
84
+ * row — a requeued continuation must not hide the PR its predecessor opened
85
+ * (#434). A `stopped` row is transparent to that selection (#870): stopping
86
+ * is terminal and the attempt will never touch the PR again, so a stopped
87
+ * duplicate must not shadow the older revisable owner of the same PR — the
88
+ * row ordering that stranded PR #870's blocking findings. Every other
89
+ * non-revisable state still decides as the newest owner: a live row
90
+ * (`running` / `claimed`) hides an older settled one because a worker is in
91
+ * flight (#844 — PR #838 was green while its newest owner was still
92
+ * `running`, and a watch keyed only to checks woke the orchestrator before
93
+ * `conductor_pr_review` was actionable), `pushed-pending` checks are still
94
+ * settling, `blocked` may resume, `orphaned` is reconciled back to live at
95
+ * startup, and `merged` means the PR lifecycle is over. When every row is
96
+ * `stopped`, the newest one answers and the predicate fails closed.
97
+ *
98
+ * `no-owner` and `not-revisable` both fail closed: a review can never act, so
99
+ * a watch must not wake, even when the checks are green.
100
+ */
101
+ export type PrReviewReadiness =
102
+ | { kind: "ready"; run: RunRecord }
103
+ | { kind: "no-owner" }
104
+ | { kind: "not-revisable"; run: RunRecord };
105
+
106
+ export function prReviewReadiness(store: Store, project: string, prUrl: string, now: number): PrReviewReadiness {
107
+ const runs = store.runsForProjectPr(project, prUrl, now - PR_LOOKUP_WINDOW_MS);
108
+ const owner = runs.find((attempt) => attempt.state !== "stopped") ?? runs.at(0);
109
+ if (owner === undefined) return { kind: "no-owner" };
110
+ return REVISABLE_RUN_STATES[owner.state] === true ? { kind: "ready", run: owner } : { kind: "not-revisable", run: owner };
111
+ }
112
+
45
113
  /**
46
114
  * Parse a raw condition, or `undefined` when it is not one of the three forms.
47
115
  *
@@ -58,7 +126,7 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
58
126
  const rest = text.slice(at + 1).trim();
59
127
  if (rest.length === 0) return undefined;
60
128
 
61
- if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-mergeable") {
129
+ if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-review-ready" || kind === "pr-mergeable") {
62
130
  return rest.startsWith("https://") ? { kind, url: rest } : undefined;
63
131
  }
64
132
  if (kind === "issue-closed") {
@@ -77,12 +145,13 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
77
145
  return undefined;
78
146
  }
79
147
 
80
- /** The six accepted forms, for a refusal that can be acted on in one turn. */
148
+ /** The seven accepted forms, for a refusal that can be acted on in one turn. */
81
149
  export const CONDITION_FORMS = [
82
150
  "pr-merged:https://github.com/owner/repo/pull/123",
83
151
  "issue-closed:123",
84
152
  "npm-version:omp-conductor@0.4.3",
85
153
  "pr-checks-green:https://github.com/owner/repo/pull/123",
154
+ "pr-review-ready:https://github.com/owner/repo/pull/123",
86
155
  "pr-mergeable:https://github.com/owner/repo/pull/123",
87
156
  "rate-limit-reset:github",
88
157
  ] as const;
@@ -154,7 +223,8 @@ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Pro
154
223
  }
155
224
 
156
225
  /**
157
- * Check every open decision that carries a condition and has not met it yet.
226
+ * Check every open decision that carries a condition; a met row is only
227
+ * revisited when its condition can stop holding (`pr-checks-green`, #808).
158
228
  *
159
229
  * Errors are swallowed per row on purpose: this runs fire-and-forget beside a
160
230
  * tick, and one deleted PR or one flaky `gh` call must not stop the rest of the
@@ -171,6 +241,16 @@ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Pro
171
241
  * was waiting and why it stopped survives. Operator questions are untouched:
172
242
  * they answer to a human and expire on the seven-day clock.
173
243
  *
244
+ * A met `pr-checks-green` / `pr-review-ready` row is the exception to "met is
245
+ * final" (#808): both are verdicts *about a commit*, so the met state is bound
246
+ * to the head it was observed at and re-read here. A head change drops the met
247
+ * state (and with it the binding) and the row re-evaluates against the live
248
+ * head — it cannot render `[CONDITION MET]` for a head whose checks are still
249
+ * running or have gone red. A row met before head binding existed has no
250
+ * binding to compare and is preserved as met as-is: nothing is guessed for it,
251
+ * and it is never withdrawn with the never-met reason. Every other condition
252
+ * is monotone or human-answered and keeps its permanent met transition.
253
+ *
174
254
  * Returns the rows that just became met, so the caller can log what changed
175
255
  * rather than a count.
176
256
  */
@@ -183,10 +263,53 @@ export async function evaluateDecisionConditions(
183
263
  ): Promise<DecisionRecord[]> {
184
264
  const met: DecisionRecord[] = [];
185
265
  for (const decision of store.openDecisions(project)) {
186
- if (decision.condition === undefined || decision.conditionMetAt !== undefined) continue;
266
+ if (decision.condition === undefined) continue;
187
267
  const condition = parseCondition(decision.condition);
188
268
  if (condition === undefined) continue;
269
+ if (decision.conditionMetAt !== undefined) {
270
+ // A met row is normally final: the digest's "act on this now" is the
271
+ // whole point, and flapping a verdict would reopen a question the
272
+ // orchestrator may already be acting on. The two head-bound conditions
273
+ // — `pr-checks-green` and `pr-review-ready` — are the ones whose
274
+ // observation can stop holding: they are about a commit, and a PR head
275
+ // change makes the old verdict stale.
276
+ if (condition.kind !== "pr-checks-green" && condition.kind !== "pr-review-ready") continue;
277
+ // A row met before head binding existed has no binding to compare and no
278
+ // way to prove a head moved. It is preserved as met rather than cleared
279
+ // and re-verified: a clear would let the settled-PR path below withdraw
280
+ // it as "before its checks were seen", which the durable ledger itself
281
+ // contradicts (review round 2, #808). No head is ever guessed for it.
282
+ if (decision.conditionHead === undefined) continue;
283
+ let head: string | undefined;
284
+ try {
285
+ head = await tracker.prHead(condition.url);
286
+ } catch {
287
+ continue;
288
+ }
289
+ // An unreadable head is not proof a head changed: flip the row only on a
290
+ // definitive observation, or it flaps on every flaky `gh` call.
291
+ if (head === undefined) continue;
292
+ if (decision.conditionHead === head) {
293
+ // A stable head is the whole story for `pr-checks-green` — checks are
294
+ // a verdict about a commit, and the commit did not move. For
295
+ // `pr-review-ready` it is only half: the row also gates on which run
296
+ // owns the PR, and a review revision resumes the owner to live
297
+ // (`running` / `claimed`) without moving the head. So a met
298
+ // review-ready row falls through to the branch below, whose shared
299
+ // readiness gate clears it the moment the verb can no longer accept —
300
+ // the repeated wake this condition exists to prevent (#844 review 1).
301
+ if (condition.kind === "pr-checks-green") continue;
302
+ } else {
303
+ // The head moved — or the row predates head binding — so the old met
304
+ // observation is no longer the PR's state. Clear it and let the pass
305
+ // below judge the live head: still green, the row re-marks with the
306
+ // new binding and wakes the orchestrator; pending or red, it stays
307
+ // pending until it is not.
308
+ store.clearDecisionConditionMet(decision.id);
309
+ }
310
+ }
189
311
  let satisfied = false;
312
+ let satisfiedHead: string | undefined;
190
313
  try {
191
314
  if (condition.kind === "pr-merged") {
192
315
  const state = await tracker.prState(condition.url);
@@ -204,21 +327,115 @@ export async function evaluateDecisionConditions(
204
327
  } else if (condition.kind === "pr-checks-green") {
205
328
  // A watch whose PR settled before its checks were seen green cannot
206
329
  // ever be met: the checks surface does not answer for a merged or
207
- // closed PR, so the row would render `pending` forever (#664).
330
+ // closed PR, so the row would render `pending` forever (#664). A row
331
+ // that was already met — then had its head move and its PR settle
332
+ // before the new head's checks — did see its checks green at the
333
+ // earlier head, and its withdrawal must record that, or the durable
334
+ // ledger gains a fact it contradicts (review round 2, #808).
208
335
  if (decision.kind === "watch") {
209
336
  const state = await tracker.prState(condition.url);
210
337
  if (state === "merged" || state === "closed") {
211
- store.resolveDecision(decision.id, "withdrawn", `PR ${state} before its checks were seen`, now());
338
+ const seenGreen = decision.conditionMetAt !== undefined;
339
+ store.resolveDecision(
340
+ decision.id,
341
+ "withdrawn",
342
+ seenGreen ? `PR ${state} after its checks were seen green` : `PR ${state} before its checks were seen`,
343
+ now(),
344
+ );
212
345
  continue;
213
346
  }
214
347
  }
348
+ // The head anchor is read BEFORE the checks: a verdict is only ever
349
+ // bound to a head that did not move while the checks were read, and
350
+ // the anchor-first order is what closes the push race in both
351
+ // directions. Reading the checks first would let a push that landed
352
+ // after the checks answered bind the old head's green verdict to the
353
+ // new, untested commit — both confirming head reads would agree on a
354
+ // head the checks were never read for (review round 1, #808).
355
+ const before = await tracker.prHead(condition.url);
215
356
  // The same conclusion values the daemon's failure classifier treats as
216
357
  // a green verdict (`success` / `neutral`, lowercased): a non-empty list
217
358
  // in which every check is terminally successful and none is failing or
218
359
  // pending (#189).
219
360
  const checks = await tracker.checkConclusions(condition.url);
220
- satisfied =
221
- checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
361
+ if (
362
+ before !== undefined &&
363
+ checks.length > 0 &&
364
+ checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true)
365
+ ) {
366
+ // Confirm the anchor still holds after the checks read: a push that
367
+ // landed anywhere inside the window surfaces as a mismatch, so the
368
+ // green list is bound only to a head it was actually read for. A
369
+ // list without a readable, stable anchor is not yet met — an
370
+ // unbound verdict is exactly the staleness this fixes, and the next
371
+ // tick can ask again for free.
372
+ const after = await tracker.prHead(condition.url);
373
+ if (before === after) {
374
+ satisfied = true;
375
+ satisfiedHead = before;
376
+ }
377
+ }
378
+ } else if (condition.kind === "pr-review-ready") {
379
+ // A watch whose PR settled before it was seen review-ready can never
380
+ // be met: neither the checks surface nor a revisable run answers for
381
+ // a merged or closed PR, so the row would render `pending` forever
382
+ // (#664). A row that was already met — then had its head move and its
383
+ // PR settle before the new head's checks — did see its PR ready at
384
+ // the earlier head, and its withdrawal must record that, the same
385
+ // history-preserving convention as pr-checks-green (review round 2,
386
+ // #808).
387
+ if (decision.kind === "watch") {
388
+ const state = await tracker.prState(condition.url);
389
+ if (state === "merged" || state === "closed") {
390
+ const seenReady = decision.conditionMetAt !== undefined;
391
+ store.resolveDecision(
392
+ decision.id,
393
+ "withdrawn",
394
+ seenReady ? `PR ${state} after it was review-ready` : `PR ${state} before it was review-ready`,
395
+ now(),
396
+ );
397
+ continue;
398
+ }
399
+ }
400
+ // The store-owned half of review readiness, shared with the verb
401
+ // (#844): the newest project-owned run that recorded this PR must be
402
+ // in a revisable state (`pushed-green` / `failed` / `killed`). A PR
403
+ // with no owner, or whose newest owner is live (`claimed` / `running`
404
+ // — the original worker or an earlier review revision) or otherwise
405
+ // non-revisable, stays pending even while its checks are green: the
406
+ // watch must not wake the orchestrator before `conductor_pr_review`
407
+ // can legally act.
408
+ const readiness = prReviewReadiness(store, project, condition.url, now());
409
+ if (readiness.kind !== "ready") {
410
+ // A met row whose owner stopped being revisable — the revision
411
+ // dispatched and the owner is live again (`running` / `claimed`)
412
+ // or the owner ended non-revisable — returns to pending on the
413
+ // same tick: the watch exists precisely so the orchestrator is
414
+ // not woken while `conductor_pr_review` would refuse. The clear
415
+ // is idempotent, and a revision that settles at the same head
416
+ // re-marks the row on a later tick.
417
+ if (decision.conditionMetAt !== undefined) store.clearDecisionConditionMet(decision.id);
418
+ continue;
419
+ }
420
+ // The tracker half, with the same fail-closed exact-head anchor as
421
+ // pr-checks-green (#808): the current head must be readable, every
422
+ // current check terminally green, and the head must not move while
423
+ // the checks were read. An unreadable or unstable head is not yet
424
+ // ready — never a verdict fabricated from a head the checks were not
425
+ // read for.
426
+ const before = await tracker.prHead(condition.url);
427
+ const checks = await tracker.checkConclusions(condition.url);
428
+ if (
429
+ before !== undefined &&
430
+ checks.length > 0 &&
431
+ checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true)
432
+ ) {
433
+ const after = await tracker.prHead(condition.url);
434
+ if (before === after) {
435
+ satisfied = true;
436
+ satisfiedHead = before;
437
+ }
438
+ }
222
439
  } else if (condition.kind === "pr-mergeable") {
223
440
  // A watch whose PR settled before it was seen mergeable is the same
224
441
  // unobservable case as checks: the mergeability literal exists only
@@ -248,8 +465,12 @@ export async function evaluateDecisionConditions(
248
465
  continue;
249
466
  }
250
467
  if (!satisfied) continue;
251
- if (store.markDecisionConditionMet(decision.id, now())) {
252
- met.push({ ...decision, conditionMetAt: now() });
468
+ if (store.markDecisionConditionMet(decision.id, now(), satisfiedHead)) {
469
+ met.push({
470
+ ...decision,
471
+ conditionMetAt: now(),
472
+ ...(satisfiedHead === undefined ? {} : { conditionHead: satisfiedHead }),
473
+ });
253
474
  }
254
475
  }
255
476
  return met;
@@ -280,8 +501,10 @@ function age(since: number, now: number): string {
280
501
  * GitHub's checks is never mistaken for a fleet that is waiting on its
281
502
  * operator. A met watch still surfaces to the orchestrator with its note and
282
503
  * the same `[CONDITION MET]` flag a met question gets. Every watch line also
283
- * names the command that closes it (`watch withdraw <id>`), because the
284
- * surface that shows a watch is where the reader learns how to end one (#664).
504
+ * names the command that closes it (`watch withdraw <id> --project <project>`),
505
+ * because the surface that shows a watch is where the reader learns how to end
506
+ * one (#664). The row's own project is always named — a digest is per-project,
507
+ * and a bare `watch withdraw <id>` is ambiguous on a host with several (#810).
285
508
  */
286
509
  export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
287
510
  const questions = open.filter((d) => d.kind !== "watch");
@@ -300,9 +523,12 @@ export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date
300
523
  `Watches (${watches.length}) — conditions the orchestrator set for itself; no operator action needed:`,
301
524
  );
302
525
  for (const d of watches) {
303
- // Each line names the verb that closes it, so the orchestrator reading a
304
- // digest does not have to know the decisions vocabulary (#664).
305
- lines.push(decisionLine(d, now, `watch withdraw ${d.id}`));
526
+ // Each line names the verb that closes it, qualified by the row's stored
527
+ // project so a copied command runs verbatim on a multi-project host
528
+ // (#810). The rows are project-scoped by construction; the stored
529
+ // project is the one the command must name, shell-quoted because a
530
+ // project name may contain characters a shell would read.
531
+ lines.push(decisionLine(d, now, `watch withdraw ${d.id} --project ${shellQuote(d.project)}`));
306
532
  }
307
533
  }
308
534
  return lines.join("\n");
package/src/diff-flags.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  * repository.
38
38
  */
39
39
 
40
- import type { PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
40
+ import type { FileLane, PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
41
41
 
42
42
  // ------------------------------------------------------------------ diff parse
43
43
 
@@ -504,6 +504,15 @@ export interface SettlementAudit {
504
504
  * had none or it could not be read. Absent means no claimed command can be
505
505
  * checked — same silence, for the same reason. */
506
506
  transcript?: string;
507
+ /**
508
+ * The effective file lane admission resolved for this run at dispatch
509
+ * (`effectiveLane` — a pre-dispatch comment declaration supersedes an older
510
+ * body one) and persisted on the row (#744/#758): the exact snapshot the
511
+ * gate enforced and the worker brief rendered. Absent means the issue was
512
+ * admitted with no declaration (fail open) — never "empty lane" — and no
513
+ * diff can then be outside it.
514
+ */
515
+ lane?: FileLane;
507
516
  }
508
517
 
509
518
  /**
@@ -522,6 +531,7 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
522
531
  const flags: SettlementFlag[] = [];
523
532
  detectWeakening(audit, flags);
524
533
  detectClaimedProof(audit, flags);
534
+ detectLaneEscape(audit, flags);
525
535
  return flags;
526
536
  }
527
537
 
@@ -538,6 +548,70 @@ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
538
548
  "the settlement could not read the PR's diff, so no `changed:` file list could be derived",
539
549
  };
540
550
 
551
+ // ---------------------------------------------------------- declared file lane
552
+
553
+ /**
554
+ * Whether a diff path counts as inside the declared lane: an explicitly
555
+ * declared path, or the co-located test of one — `foo.ts` vouches for
556
+ * `foo.test.ts`, which is the "obviously intended" case. Other test shapes
557
+ * (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule is the
558
+ * shape the fleet actually uses, and a lane that wants a differently-shaped
559
+ * sibling declares it. Deliberately one-directional: a lane that declares a
560
+ * *test* file does not vouch for its source, because declaring the test alone
561
+ * is a narrower promise and widening it silently is exactly what this flag
562
+ * exists to name. A containing directory never vouches for its contents
563
+ * either — the lane grammar names files, and a lane that means "everything
564
+ * under `src/`" fails open exactly as an undeclared one would if it cannot
565
+ * name them.
566
+ */
567
+ function withinLane(path: string, declared: readonly string[]): boolean {
568
+ if (declared.includes(path)) return true;
569
+ for (const d of declared) {
570
+ if (coLocatedTest(d) === path) return true;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ /** `omp/src/verbs/server.ts` → `omp/src/verbs/server.test.ts`; undefined when
576
+ * the declared path has no file extension to splice before, since the
577
+ * co-located-test shape is defined by an extension. */
578
+ function coLocatedTest(declared: string): string | undefined {
579
+ const dot = declared.lastIndexOf(".");
580
+ const slash = declared.lastIndexOf("/");
581
+ if (dot <= slash + 1) return undefined;
582
+ return `${declared.slice(0, dot)}.test${declared.slice(dot)}`;
583
+ }
584
+
585
+ /**
586
+ * A diff that escapes its issue's declared file lane (#739).
587
+ *
588
+ * The lane is the effective declaration admission resolved at dispatch —
589
+ * `effectiveLane(body, comments)`, so a pre-dispatch comment beats an older
590
+ * body declaration — and the flag uses that resolved snapshot, never a re-parse
591
+ * of the body. The finding names every delivered file outside it, which is the
592
+ * part a reviewer is worst placed to notice: the diff's own file list is the
593
+ * only surface that shows the escape, and reading PR file lists by hand is
594
+ * exactly what nothing else in the loop does.
595
+ *
596
+ * Fail-open, like admission: an issue with no lane declaration has nothing to
597
+ * escape, so no flag — a flag on every undeclared run would be noise within a
598
+ * day, worse than no flag. Advisory like every other flag here: a widened lane
599
+ * is often legitimate, and the orchestrator is the judge this exists to brief.
600
+ */
601
+ function detectLaneEscape(audit: SettlementAudit, flags: SettlementFlag[]): void {
602
+ const lane = audit.lane;
603
+ if (lane === undefined || lane.files.length === 0) return;
604
+ const outside = audit.diff.files
605
+ .map((f) => f.path)
606
+ .filter((path) => !withinLane(path, lane.files));
607
+ if (outside.length === 0) return;
608
+ flags.push({
609
+ kind: "lane-escape",
610
+ file: "(lane)",
611
+ detail: `PR diff touches files outside the declared file lane: ${outside.join(", ")}`,
612
+ });
613
+ }
614
+
541
615
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
542
616
  // A test file that left one path and arrived at another is a move, not a
543
617
  // deletion. `status: renamed` covers the renames git detected; the basename