omp-conductor 0.17.1 → 0.18.1

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 +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  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 +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -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,64 @@ 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 same newest-first resolution the review verb uses: the
83
+ * newest attempt of this project that recorded the PR within the
84
+ * recent-history window (`runsForProjectPr`), never the issue's newest row —
85
+ * a requeued continuation must not hide the PR its predecessor opened
86
+ * (#434), and a newer live owner hides an older settled one. That older
87
+ * settled row is exactly the shape dogfood #844 started from: PR #838 was
88
+ * green while its newest owner was still `running`, and a watch keyed only to
89
+ * checks woke the orchestrator before `conductor_pr_review` was actionable.
90
+ *
91
+ * `no-owner` and `not-revisable` both fail closed: a review can never act, so
92
+ * a watch must not wake, even when the checks are green.
93
+ */
94
+ export type PrReviewReadiness =
95
+ | { kind: "ready"; run: RunRecord }
96
+ | { kind: "no-owner" }
97
+ | { kind: "not-revisable"; run: RunRecord };
98
+
99
+ export function prReviewReadiness(store: Store, project: string, prUrl: string, now: number): PrReviewReadiness {
100
+ const run = store.runsForProjectPr(project, prUrl, now - PR_LOOKUP_WINDOW_MS)[0];
101
+ if (run === undefined) return { kind: "no-owner" };
102
+ return REVISABLE_RUN_STATES[run.state] === true ? { kind: "ready", run } : { kind: "not-revisable", run };
103
+ }
104
+
45
105
  /**
46
106
  * Parse a raw condition, or `undefined` when it is not one of the three forms.
47
107
  *
@@ -58,7 +118,7 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
58
118
  const rest = text.slice(at + 1).trim();
59
119
  if (rest.length === 0) return undefined;
60
120
 
61
- if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-mergeable") {
121
+ if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-review-ready" || kind === "pr-mergeable") {
62
122
  return rest.startsWith("https://") ? { kind, url: rest } : undefined;
63
123
  }
64
124
  if (kind === "issue-closed") {
@@ -77,12 +137,13 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
77
137
  return undefined;
78
138
  }
79
139
 
80
- /** The six accepted forms, for a refusal that can be acted on in one turn. */
140
+ /** The seven accepted forms, for a refusal that can be acted on in one turn. */
81
141
  export const CONDITION_FORMS = [
82
142
  "pr-merged:https://github.com/owner/repo/pull/123",
83
143
  "issue-closed:123",
84
144
  "npm-version:omp-conductor@0.4.3",
85
145
  "pr-checks-green:https://github.com/owner/repo/pull/123",
146
+ "pr-review-ready:https://github.com/owner/repo/pull/123",
86
147
  "pr-mergeable:https://github.com/owner/repo/pull/123",
87
148
  "rate-limit-reset:github",
88
149
  ] as const;
@@ -154,13 +215,34 @@ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Pro
154
215
  }
155
216
 
156
217
  /**
157
- * Check every open decision that carries a condition and has not met it yet.
218
+ * Check every open decision that carries a condition; a met row is only
219
+ * revisited when its condition can stop holding (`pr-checks-green`, #808).
158
220
  *
159
221
  * Errors are swallowed per row on purpose: this runs fire-and-forget beside a
160
222
  * tick, and one deleted PR or one flaky `gh` call must not stop the rest of the
161
223
  * pass. A condition that could not be checked is simply not met this time, and
162
224
  * the next tick asks again for free.
163
225
  *
226
+ * A watch (#459) whose PR condition can no longer be observed — the PR merged
227
+ * or closed before the requested condition was seen — is withdrawn in this same
228
+ * pass rather than left rendering `condition:pending` forever: the mediated
229
+ * surfaces for checks and mergeability do not answer for a settled PR, so the
230
+ * row could only ever stay unmet, and a watch has deliberately no seven-day
231
+ * expiry to close it. The withdrawal is the same durable `state=withdrawn`
232
+ * resolution an operator's `watch withdraw` writes, so the audit trail of what
233
+ * was waiting and why it stopped survives. Operator questions are untouched:
234
+ * they answer to a human and expire on the seven-day clock.
235
+ *
236
+ * A met `pr-checks-green` / `pr-review-ready` row is the exception to "met is
237
+ * final" (#808): both are verdicts *about a commit*, so the met state is bound
238
+ * to the head it was observed at and re-read here. A head change drops the met
239
+ * state (and with it the binding) and the row re-evaluates against the live
240
+ * head — it cannot render `[CONDITION MET]` for a head whose checks are still
241
+ * running or have gone red. A row met before head binding existed has no
242
+ * binding to compare and is preserved as met as-is: nothing is guessed for it,
243
+ * and it is never withdrawn with the never-met reason. Every other condition
244
+ * is monotone or human-answered and keeps its permanent met transition.
245
+ *
164
246
  * Returns the rows that just became met, so the caller can log what changed
165
247
  * rather than a count.
166
248
  */
@@ -173,26 +255,190 @@ export async function evaluateDecisionConditions(
173
255
  ): Promise<DecisionRecord[]> {
174
256
  const met: DecisionRecord[] = [];
175
257
  for (const decision of store.openDecisions(project)) {
176
- if (decision.condition === undefined || decision.conditionMetAt !== undefined) continue;
258
+ if (decision.condition === undefined) continue;
177
259
  const condition = parseCondition(decision.condition);
178
260
  if (condition === undefined) continue;
261
+ if (decision.conditionMetAt !== undefined) {
262
+ // A met row is normally final: the digest's "act on this now" is the
263
+ // whole point, and flapping a verdict would reopen a question the
264
+ // orchestrator may already be acting on. The two head-bound conditions
265
+ // — `pr-checks-green` and `pr-review-ready` — are the ones whose
266
+ // observation can stop holding: they are about a commit, and a PR head
267
+ // change makes the old verdict stale.
268
+ if (condition.kind !== "pr-checks-green" && condition.kind !== "pr-review-ready") continue;
269
+ // A row met before head binding existed has no binding to compare and no
270
+ // way to prove a head moved. It is preserved as met rather than cleared
271
+ // and re-verified: a clear would let the settled-PR path below withdraw
272
+ // it as "before its checks were seen", which the durable ledger itself
273
+ // contradicts (review round 2, #808). No head is ever guessed for it.
274
+ if (decision.conditionHead === undefined) continue;
275
+ let head: string | undefined;
276
+ try {
277
+ head = await tracker.prHead(condition.url);
278
+ } catch {
279
+ continue;
280
+ }
281
+ // An unreadable head is not proof a head changed: flip the row only on a
282
+ // definitive observation, or it flaps on every flaky `gh` call.
283
+ if (head === undefined) continue;
284
+ if (decision.conditionHead === head) {
285
+ // A stable head is the whole story for `pr-checks-green` — checks are
286
+ // a verdict about a commit, and the commit did not move. For
287
+ // `pr-review-ready` it is only half: the row also gates on which run
288
+ // owns the PR, and a review revision resumes the owner to live
289
+ // (`running` / `claimed`) without moving the head. So a met
290
+ // review-ready row falls through to the branch below, whose shared
291
+ // readiness gate clears it the moment the verb can no longer accept —
292
+ // the repeated wake this condition exists to prevent (#844 review 1).
293
+ if (condition.kind === "pr-checks-green") continue;
294
+ } else {
295
+ // The head moved — or the row predates head binding — so the old met
296
+ // observation is no longer the PR's state. Clear it and let the pass
297
+ // below judge the live head: still green, the row re-marks with the
298
+ // new binding and wakes the orchestrator; pending or red, it stays
299
+ // pending until it is not.
300
+ store.clearDecisionConditionMet(decision.id);
301
+ }
302
+ }
179
303
  let satisfied = false;
304
+ let satisfiedHead: string | undefined;
180
305
  try {
181
306
  if (condition.kind === "pr-merged") {
182
- satisfied = (await tracker.prState(condition.url)) === "merged";
307
+ const state = await tracker.prState(condition.url);
308
+ if (state === "merged") {
309
+ satisfied = true;
310
+ } else if (decision.kind === "watch" && state === "closed") {
311
+ // The PR closed without merging: `pr-merged` can never be observed.
312
+ store.resolveDecision(decision.id, "withdrawn", "PR closed without merging", now());
313
+ continue;
314
+ }
183
315
  } else if (condition.kind === "issue-closed") {
184
316
  satisfied = (await tracker.issueState(condition.issue)) === "closed";
185
317
  } else if (condition.kind === "npm-version") {
186
318
  satisfied = await probes.npm(condition.spec);
187
319
  } else if (condition.kind === "pr-checks-green") {
320
+ // A watch whose PR settled before its checks were seen green cannot
321
+ // ever be met: the checks surface does not answer for a merged or
322
+ // closed PR, so the row would render `pending` forever (#664). A row
323
+ // that was already met — then had its head move and its PR settle
324
+ // before the new head's checks — did see its checks green at the
325
+ // earlier head, and its withdrawal must record that, or the durable
326
+ // ledger gains a fact it contradicts (review round 2, #808).
327
+ if (decision.kind === "watch") {
328
+ const state = await tracker.prState(condition.url);
329
+ if (state === "merged" || state === "closed") {
330
+ const seenGreen = decision.conditionMetAt !== undefined;
331
+ store.resolveDecision(
332
+ decision.id,
333
+ "withdrawn",
334
+ seenGreen ? `PR ${state} after its checks were seen green` : `PR ${state} before its checks were seen`,
335
+ now(),
336
+ );
337
+ continue;
338
+ }
339
+ }
340
+ // The head anchor is read BEFORE the checks: a verdict is only ever
341
+ // bound to a head that did not move while the checks were read, and
342
+ // the anchor-first order is what closes the push race in both
343
+ // directions. Reading the checks first would let a push that landed
344
+ // after the checks answered bind the old head's green verdict to the
345
+ // new, untested commit — both confirming head reads would agree on a
346
+ // head the checks were never read for (review round 1, #808).
347
+ const before = await tracker.prHead(condition.url);
188
348
  // The same conclusion values the daemon's failure classifier treats as
189
349
  // a green verdict (`success` / `neutral`, lowercased): a non-empty list
190
350
  // in which every check is terminally successful and none is failing or
191
351
  // pending (#189).
192
352
  const checks = await tracker.checkConclusions(condition.url);
193
- satisfied =
194
- checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
353
+ if (
354
+ before !== undefined &&
355
+ checks.length > 0 &&
356
+ checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true)
357
+ ) {
358
+ // Confirm the anchor still holds after the checks read: a push that
359
+ // landed anywhere inside the window surfaces as a mismatch, so the
360
+ // green list is bound only to a head it was actually read for. A
361
+ // list without a readable, stable anchor is not yet met — an
362
+ // unbound verdict is exactly the staleness this fixes, and the next
363
+ // tick can ask again for free.
364
+ const after = await tracker.prHead(condition.url);
365
+ if (before === after) {
366
+ satisfied = true;
367
+ satisfiedHead = before;
368
+ }
369
+ }
370
+ } else if (condition.kind === "pr-review-ready") {
371
+ // A watch whose PR settled before it was seen review-ready can never
372
+ // be met: neither the checks surface nor a revisable run answers for
373
+ // a merged or closed PR, so the row would render `pending` forever
374
+ // (#664). A row that was already met — then had its head move and its
375
+ // PR settle before the new head's checks — did see its PR ready at
376
+ // the earlier head, and its withdrawal must record that, the same
377
+ // history-preserving convention as pr-checks-green (review round 2,
378
+ // #808).
379
+ if (decision.kind === "watch") {
380
+ const state = await tracker.prState(condition.url);
381
+ if (state === "merged" || state === "closed") {
382
+ const seenReady = decision.conditionMetAt !== undefined;
383
+ store.resolveDecision(
384
+ decision.id,
385
+ "withdrawn",
386
+ seenReady ? `PR ${state} after it was review-ready` : `PR ${state} before it was review-ready`,
387
+ now(),
388
+ );
389
+ continue;
390
+ }
391
+ }
392
+ // The store-owned half of review readiness, shared with the verb
393
+ // (#844): the newest project-owned run that recorded this PR must be
394
+ // in a revisable state (`pushed-green` / `failed` / `killed`). A PR
395
+ // with no owner, or whose newest owner is live (`claimed` / `running`
396
+ // — the original worker or an earlier review revision) or otherwise
397
+ // non-revisable, stays pending even while its checks are green: the
398
+ // watch must not wake the orchestrator before `conductor_pr_review`
399
+ // can legally act.
400
+ const readiness = prReviewReadiness(store, project, condition.url, now());
401
+ if (readiness.kind !== "ready") {
402
+ // A met row whose owner stopped being revisable — the revision
403
+ // dispatched and the owner is live again (`running` / `claimed`)
404
+ // or the owner ended non-revisable — returns to pending on the
405
+ // same tick: the watch exists precisely so the orchestrator is
406
+ // not woken while `conductor_pr_review` would refuse. The clear
407
+ // is idempotent, and a revision that settles at the same head
408
+ // re-marks the row on a later tick.
409
+ if (decision.conditionMetAt !== undefined) store.clearDecisionConditionMet(decision.id);
410
+ continue;
411
+ }
412
+ // The tracker half, with the same fail-closed exact-head anchor as
413
+ // pr-checks-green (#808): the current head must be readable, every
414
+ // current check terminally green, and the head must not move while
415
+ // the checks were read. An unreadable or unstable head is not yet
416
+ // ready — never a verdict fabricated from a head the checks were not
417
+ // read for.
418
+ const before = await tracker.prHead(condition.url);
419
+ const checks = await tracker.checkConclusions(condition.url);
420
+ if (
421
+ before !== undefined &&
422
+ checks.length > 0 &&
423
+ checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true)
424
+ ) {
425
+ const after = await tracker.prHead(condition.url);
426
+ if (before === after) {
427
+ satisfied = true;
428
+ satisfiedHead = before;
429
+ }
430
+ }
195
431
  } else if (condition.kind === "pr-mergeable") {
432
+ // A watch whose PR settled before it was seen mergeable is the same
433
+ // unobservable case as checks: the mergeability literal exists only
434
+ // while the PR is open (#664).
435
+ if (decision.kind === "watch") {
436
+ const state = await tracker.prState(condition.url);
437
+ if (state === "merged" || state === "closed") {
438
+ store.resolveDecision(decision.id, "withdrawn", `PR ${state} before it was seen mergeable`, now());
439
+ continue;
440
+ }
441
+ }
196
442
  // `clean` is the tracker's "this PR can merge" literal; `unknown` and a
197
443
  // conflict are both unsatisfied (#189).
198
444
  satisfied = (await tracker.mergeable(condition.url)) === "clean";
@@ -211,8 +457,12 @@ export async function evaluateDecisionConditions(
211
457
  continue;
212
458
  }
213
459
  if (!satisfied) continue;
214
- if (store.markDecisionConditionMet(decision.id, now())) {
215
- met.push({ ...decision, conditionMetAt: now() });
460
+ if (store.markDecisionConditionMet(decision.id, now(), satisfiedHead)) {
461
+ met.push({
462
+ ...decision,
463
+ conditionMetAt: now(),
464
+ ...(satisfiedHead === undefined ? {} : { conditionHead: satisfiedHead }),
465
+ });
216
466
  }
217
467
  }
218
468
  return met;
@@ -242,7 +492,11 @@ function age(since: number, now: number): string {
242
492
  * watch heading with no instruction to resolve it, so a fleet at rest behind
243
493
  * GitHub's checks is never mistaken for a fleet that is waiting on its
244
494
  * operator. A met watch still surfaces to the orchestrator with its note and
245
- * the same `[CONDITION MET]` flag a met question gets.
495
+ * the same `[CONDITION MET]` flag a met question gets. Every watch line also
496
+ * names the command that closes it (`watch withdraw <id> --project <project>`),
497
+ * because the surface that shows a watch is where the reader learns how to end
498
+ * one (#664). The row's own project is always named — a digest is per-project,
499
+ * and a bare `watch withdraw <id>` is ambiguous on a host with several (#810).
246
500
  */
247
501
  export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
248
502
  const questions = open.filter((d) => d.kind !== "watch");
@@ -261,14 +515,23 @@ export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date
261
515
  `Watches (${watches.length}) — conditions the orchestrator set for itself; no operator action needed:`,
262
516
  );
263
517
  for (const d of watches) {
264
- lines.push(decisionLine(d, now));
518
+ // Each line names the verb that closes it, qualified by the row's stored
519
+ // project so a copied command runs verbatim on a multi-project host
520
+ // (#810). The rows are project-scoped by construction; the stored
521
+ // project is the one the command must name, shell-quoted because a
522
+ // project name may contain characters a shell would read.
523
+ lines.push(decisionLine(d, now, `watch withdraw ${d.id} --project ${shellQuote(d.project)}`));
265
524
  }
266
525
  }
267
526
  return lines.join("\n");
268
527
  }
269
528
 
270
- /** One digest row: id, age, what it blocks, the met flag, and the note. */
271
- function decisionLine(d: DecisionRecord, now: number): string {
529
+ /**
530
+ * One digest row: id, age, what it blocks, the met flag, and the note.
531
+ * `closing` names the command that ends the row, rendered after the note.
532
+ */
533
+ function decisionLine(d: DecisionRecord, now: number, closing?: string): string {
272
534
  const flag = d.conditionMetAt === undefined ? "" : " [CONDITION MET — act on this now]";
273
- return `- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}`;
535
+ const end = closing === undefined ? "" : ` — end with: omp-conductor ${closing}`;
536
+ return `- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}${end}`;
274
537
  }