omp-conductor 0.4.2 → 0.4.3

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/src/daemon.ts CHANGED
@@ -37,6 +37,8 @@ import { createReportOutbox, formatOpenReports } from "./reports.ts";
37
37
  import { recordReleaseBlock } from "./release-policy.ts";
38
38
  import { branchName, route } from "./routing.ts";
39
39
  import type { Routed, UnroutableReason } from "./routing.ts";
40
+ import { evaluateDecisionConditions, probeNpmVersion } from "./decisions.ts";
41
+ import { classifyRun, type ClassifyFacts } from "./failure-class.ts";
40
42
  import { dbPath, openStore } from "./store.ts";
41
43
  import { makeTracker } from "./tracker/github.ts";
42
44
  import { RELEASE_SHAPES } from "./types.ts";
@@ -53,6 +55,8 @@ import type {
53
55
  RepoTarget,
54
56
  ReportRecord,
55
57
  ResolvedGrants,
58
+ FailureClass,
59
+ RecoveryAction,
56
60
  RunRecord,
57
61
  RunState,
58
62
  SettlementFlag,
@@ -2191,6 +2195,46 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2191
2195
  // through the pool without blocking this five-minute tick.
2192
2196
  await settlePushedGreen(d);
2193
2197
 
2198
+ // Immediately after settlement and before any routing, so a class is on the
2199
+ // row before the next dispatch decision reads its budgets (#132). Above the
2200
+ // pause gate deliberately: classification and label reconciliation are
2201
+ // maintenance, and the four phantom `agent:failed` issues this exists to clear
2202
+ // are exactly as misleading on a parked fleet as on a busy one.
2203
+ //
2204
+ // Each is guarded on its own: a tracker that fails mid-classification must not
2205
+ // stop the label reconcile, and neither may stop the tick.
2206
+ try {
2207
+ await classifyAndRecover(d);
2208
+ } catch (err) {
2209
+ log(`classification sweep failed: ${errText(err)}`);
2210
+ }
2211
+ try {
2212
+ await reconcileStaleLabels(d);
2213
+ } catch (err) {
2214
+ log(`label reconcile failed: ${errText(err)}`);
2215
+ }
2216
+
2217
+ // Ledger maintenance, above the pause gate for the same reason the stall watch
2218
+ // is: a paused fleet still owes its operator the questions it asked, and a
2219
+ // condition that came true while dispatch was parked is exactly the thing the
2220
+ // orchestrator has to see on its next tick.
2221
+ //
2222
+ // Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
2223
+ // because it shells out to `gh` and `npm`, and a registry that hangs must cost
2224
+ // one unevaluated condition rather than the tick.
2225
+ for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
2226
+ log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
2227
+ }
2228
+ void evaluateDecisionConditions(d.store, d.project.name, d.tracker, probeNpmVersion, Date.now)
2229
+ .then((met) => {
2230
+ for (const decision of met) {
2231
+ log(`decision ${decision.id} condition met (${decision.condition ?? "?"}) — surfacing on the next tick`);
2232
+ }
2233
+ })
2234
+ .catch((err: unknown) => {
2235
+ log(`decision condition pass failed: ${errText(err)}`);
2236
+ });
2237
+
2194
2238
  // A paused fleet claims nothing. Checked first so pausing takes effect on the
2195
2239
  // next tick without signalling the process.
2196
2240
  if (isPaused()) return;
@@ -2746,6 +2790,299 @@ export function prepareConductor(): void {
2746
2790
  setPaused(true);
2747
2791
  }
2748
2792
 
2793
+ /** Bounded per tick: each row costs tracker calls to gather facts for. */
2794
+ const CLASSIFY_BATCH = 20;
2795
+
2796
+ /** Tool calls quoted as evidence for a run that spun to its turn cap. */
2797
+ const SPIN_EVIDENCE_CALLS = 10;
2798
+
2799
+ /**
2800
+ * The last few tool names a transcript recorded, newest last.
2801
+ *
2802
+ * `turn-cap-spinning` escalates rather than requeueing, and the acceptance
2803
+ * criterion is that the escalation carries evidence of what the worker was doing
2804
+ * when it hit the cap — otherwise the orchestrator opens the transcript and
2805
+ * re-derives it, which is the manual triage this whole sweep removes.
2806
+ */
2807
+ export function lastToolCalls(sessionFile: string | undefined, limit = SPIN_EVIDENCE_CALLS): string[] {
2808
+ if (sessionFile === undefined) return [];
2809
+ let text: string;
2810
+ try {
2811
+ text = readFileSync(sessionFile, "utf8");
2812
+ } catch {
2813
+ return [];
2814
+ }
2815
+ const names: string[] = [];
2816
+ for (const line of text.split("\n")) {
2817
+ if (line.length === 0) continue;
2818
+ let row: unknown;
2819
+ try {
2820
+ row = JSON.parse(line) as unknown;
2821
+ } catch {
2822
+ continue;
2823
+ }
2824
+ if (row === null || typeof row !== "object") continue;
2825
+ const rec = row as { readonly [key: string]: unknown };
2826
+ // Both shapes the harness has written: a top-level tool event, and a tool
2827
+ // block inside an assistant message.
2828
+ const direct = rec["toolName"];
2829
+ if (typeof direct === "string") {
2830
+ names.push(direct);
2831
+ continue;
2832
+ }
2833
+ const message = rec["message"];
2834
+ if (message === null || typeof message !== "object") continue;
2835
+ const content = (message as { readonly [key: string]: unknown })["content"];
2836
+ if (!Array.isArray(content)) continue;
2837
+ for (const part of content) {
2838
+ if (part === null || typeof part !== "object") continue;
2839
+ const p = part as { readonly [key: string]: unknown };
2840
+ if (p["type"] !== "tool_use") continue;
2841
+ const name = p["name"];
2842
+ if (typeof name === "string") names.push(name);
2843
+ }
2844
+ }
2845
+ return names.slice(-limit);
2846
+ }
2847
+
2848
+ /**
2849
+ * Classify every unclassified terminal run, persist the verdict, and perform the
2850
+ * one recovery its class names (#132).
2851
+ *
2852
+ * Half this fleet's spend produced no merged PR, and every one of those runs
2853
+ * ended at a human who re-derived the same triage by hand and then threw the
2854
+ * conclusion away. The mechanical classes — a cancelled runner, a kill from a
2855
+ * daemon restart, a green PR whose base moved, a row whose PR had already merged
2856
+ * — need no judgement at all; the genuinely human ones are worth a person's
2857
+ * attention only if they arrive with their evidence already gathered.
2858
+ *
2859
+ * Facts are fetched per row and only the ones that row needs: a `killed` row
2860
+ * costs nothing, a `failed` row with a PR costs a state read and a check read.
2861
+ * A `pushed-green` row that classifies to nothing is left completely untouched —
2862
+ * it is healthy, and writing a class onto it would take it out of this sweep for
2863
+ * good.
2864
+ */
2865
+ export async function classifyAndRecover(d: Deps): Promise<void> {
2866
+ const { project, tracker, store } = d;
2867
+ for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) {
2868
+ const facts: ClassifyFacts = {};
2869
+ try {
2870
+ if (run.prUrl !== undefined) {
2871
+ const pr = await tracker.prState(run.prUrl);
2872
+ if (pr !== undefined) facts.pr = pr;
2873
+ if (run.state === "pushed-green" && facts.pr === "open") {
2874
+ facts.mergeable = await tracker.mergeable(run.prUrl);
2875
+ }
2876
+ if (run.state === "failed" && facts.pr === "open") {
2877
+ facts.checks = await tracker.checkConclusions(run.prUrl);
2878
+ }
2879
+ }
2880
+ } catch (err) {
2881
+ // Per row, like every other sweep here: one unreachable PR must not stop
2882
+ // the rest from being classified. The next tick asks again for free.
2883
+ log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) — retrying next tick`);
2884
+ continue;
2885
+ }
2886
+
2887
+ const { cls, recovery, evidence } = classifyRun(run, facts);
2888
+
2889
+ // A healthy green PR is not a failure of any class. Leaving the row
2890
+ // unclassified is what keeps it eligible for the sweep on the tick where its
2891
+ // base does move under it.
2892
+ if (run.state === "pushed-green" && cls === "unknown") continue;
2893
+
2894
+ store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery });
2895
+ log(`#${run.issue} classified ${cls} → ${recovery}: ${evidence}`);
2896
+ await recoverRun(d, run, cls, recovery, evidence);
2897
+ }
2898
+ }
2899
+
2900
+ /** Performs the one action a class names. Never chooses one of its own. */
2901
+ async function recoverRun(
2902
+ d: Deps,
2903
+ run: RunRecord,
2904
+ cls: FailureClass,
2905
+ recovery: RecoveryAction,
2906
+ evidence: string,
2907
+ ): Promise<void> {
2908
+ const { project, tracker, store } = d;
2909
+ const inProgress = project.stateLabels.inProgress;
2910
+
2911
+ if (recovery === "settle") {
2912
+ // Label before row, copied from `settlePushedGreen` where the order is the
2913
+ // whole safety argument: writing the terminal state first would put this row
2914
+ // beyond every later tick, and a tracker that then failed on the label would
2915
+ // strand `agent:in-progress` with nothing left to retry it (#18).
2916
+ if (!(await releaseInProgress(d, run.issue, `PR merged: ${evidence}`))) return;
2917
+ store.updateRun(run.id, { state: "merged", endedAt: Date.now(), recoveredAt: Date.now() });
2918
+ log(`#${run.issue} settled from ${cls}: ${evidence}`);
2919
+ return;
2920
+ }
2921
+
2922
+ if (recovery === "continue") {
2923
+ // The branch is retained and its PR is open, so #50's continuation guard
2924
+ // admits it: the next tick reattaches the branch and briefs a rebase.
2925
+ store.updateRun(run.id, {
2926
+ state: "killed",
2927
+ lastError:
2928
+ "merge-conflict: base moved under a green PR — rebase, regenerate recorded artifacts, push without force",
2929
+ recoveredAt: Date.now(),
2930
+ });
2931
+ if (!(await swapToQueue(d, run.issue, inProgress))) return;
2932
+ log(`#${run.issue} requeued for a rebase continuation: ${evidence}`);
2933
+ return;
2934
+ }
2935
+
2936
+ if (recovery === "requeue") {
2937
+ // Only when the tracker still shows this issue as ours to hand back. An
2938
+ // issue that is closed, or has no state label, was resolved by another route
2939
+ // and requeueing it would dispatch work nobody asked for.
2940
+ const state = await tracker.issueState(run.issue).catch(() => undefined);
2941
+ if (state !== "open") {
2942
+ log(`#${run.issue} not requeued from ${cls}: issue is ${state ?? "unreadable"}`);
2943
+ return;
2944
+ }
2945
+ const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
2946
+ if (!(await swapToQueue(d, run.issue, label))) return;
2947
+ store.updateRun(run.id, { recoveredAt: Date.now() });
2948
+ log(`#${run.issue} requeued from ${cls}: ${evidence}`);
2949
+ return;
2950
+ }
2951
+
2952
+ if (recovery === "rerun-checks") {
2953
+ if (run.prUrl === undefined) return;
2954
+ try {
2955
+ await tracker.rerunFailedChecks(run.prUrl);
2956
+ } catch (err) {
2957
+ log(`#${run.issue} check re-run failed (${errText(err)}) — retrying next tick`);
2958
+ return;
2959
+ }
2960
+ // Back to pending rather than green: the existing settle sweep re-verifies
2961
+ // it against the recorded head on a later tick, so nothing here has to guess
2962
+ // whether the re-run passed.
2963
+ store.updateRun(run.id, { state: "pushed-pending", lastError: undefined, recoveredAt: Date.now() });
2964
+ log(`#${run.issue} re-ran infrastructure checks: ${evidence}`);
2965
+ return;
2966
+ }
2967
+
2968
+ if (recovery === "escalate") {
2969
+ const detail = [evidence];
2970
+ if (cls === "turn-cap-spinning") {
2971
+ const calls = lastToolCalls(run.sessionFile);
2972
+ detail.push(
2973
+ calls.length === 0
2974
+ ? "transcript unreadable — no tool calls could be recovered"
2975
+ : `Last ${calls.length} tool calls: ${calls.join(" → ")}`,
2976
+ );
2977
+ }
2978
+ if (run.lastError !== undefined && cls !== "question") detail.push(run.lastError);
2979
+ detail.push(`Session: ${run.sessionFile ?? "(no transcript)"}`);
2980
+ // The class and the run are in the summary, which is what the notifications
2981
+ // ledger dedupes on — so one class escalates once per run rather than every
2982
+ // five minutes.
2983
+ await safeEscalate(d, {
2984
+ tier: 1,
2985
+ project: project.name,
2986
+ issue: run.issue,
2987
+ runId: run.id,
2988
+ summary: `[${cls}] #${run.issue} attempt ${run.attempt}: ${evidence}`,
2989
+ detail: detail.join("\n"),
2990
+ });
2991
+ // The hand-off IS the recovery for these classes: there is nothing else this
2992
+ // package can do, and leaving the row unrecovered would re-escalate forever.
2993
+ store.updateRun(run.id, { recoveredAt: Date.now() });
2994
+ return;
2995
+ }
2996
+
2997
+ // `hold` (orphan-dirty) and `none`: recorded, nothing performed. The existing
2998
+ // unsalvaged-WIP admission hold already fails dispatch closed until an
2999
+ // operator acknowledges the tree, which is the only safe move when the
3000
+ // worktree holds the only copy of real work.
3001
+ }
3002
+
3003
+ /**
3004
+ * Swap a state label for the queue label, in that order.
3005
+ *
3006
+ * Both writes go through the same Tracker port the dispatcher claimed with, so
3007
+ * orphan detection stays trustworthy. Returns false when either half failed, so
3008
+ * the caller leaves the row for the next tick rather than recording a recovery
3009
+ * that did not happen.
3010
+ */
3011
+ async function swapToQueue(
3012
+ d: Pick<Deps, "project" | "tracker">,
3013
+ issue: number,
3014
+ label: string,
3015
+ ): Promise<boolean> {
3016
+ try {
3017
+ await d.tracker.removeLabel(issue, label);
3018
+ await d.tracker.addLabel(issue, d.project.queueLabel);
3019
+ return true;
3020
+ } catch (err) {
3021
+ log(`#${issue} could not be requeued (${errText(err)}) — retrying next tick`);
3022
+ return false;
3023
+ }
3024
+ }
3025
+
3026
+ /** Bounded listing per state label, so one reconcile cannot walk a whole repo. */
3027
+ const RECONCILE_LIMIT = 50;
3028
+
3029
+ /**
3030
+ * Clear state labels from issues that no longer need them (#132's `superseded`).
3031
+ *
3032
+ * Two structural signals, both cheap and both observed on this fleet: an issue
3033
+ * that is closed but still carries `agent:*`, and an open issue carrying
3034
+ * `failed` whose sub-issues have all closed. On 2026-08-09 four issues (#307,
3035
+ * #297, #140, #82) carried `agent:failed` while every one of them was already
3036
+ * complete — the label was residue of a turns-cap kill from two days earlier,
3037
+ * and nothing in the loop ever revisited it. The board counted four phantom
3038
+ * failures while the genuinely stuck issues were invisible.
3039
+ *
3040
+ * Positive evidence only. A tracker that cannot list answers empty, and an empty
3041
+ * answer removes nothing: a reconcile that guessed would strip the interlock
3042
+ * that keeps two workers off one issue.
3043
+ */
3044
+ export async function reconcileStaleLabels(d: Deps): Promise<void> {
3045
+ const { project, tracker, store } = d;
3046
+ const labels = [project.stateLabels.failed, project.stateLabels.blocked, project.stateLabels.inProgress];
3047
+
3048
+ for (const label of labels) {
3049
+ const carrying = await tracker.listLabeled(label, RECONCILE_LIMIT).catch(() => []);
3050
+ for (const issue of carrying) {
3051
+ if (issue.state === "closed") {
3052
+ // Never retain an `agent:*` label on a closed issue: the work is done by
3053
+ // some route, and the label only makes the board lie about it.
3054
+ try {
3055
+ await tracker.removeLabel(issue.number, label);
3056
+ log(`#${issue.number} reconciled: closed issue no longer carries ${label}`);
3057
+ } catch (err) {
3058
+ log(`#${issue.number} could not drop ${label} (${errText(err)}) — retrying next tick`);
3059
+ }
3060
+ continue;
3061
+ }
3062
+
3063
+ if (label !== project.stateLabels.failed) continue;
3064
+ const children = await tracker.childrenOf(issue.number).catch(() => []);
3065
+ if (children.length === 0 || children.some((c) => c.state !== "closed")) continue;
3066
+
3067
+ const key = `${project.name}:superseded:${issue.number}`;
3068
+ if (store.wasNotified(key)) continue;
3069
+ const list = children.map((c) => `#${c.number}`).join(", ");
3070
+ try {
3071
+ await tracker.removeLabel(issue.number, label);
3072
+ await tracker.comment(
3073
+ issue.number,
3074
+ `superseded: all sub-issues closed (${list}) — this label was stale; propose closing if the ` +
3075
+ `acceptance criteria are met on the default branch.`,
3076
+ );
3077
+ store.markNotified(key);
3078
+ log(`#${issue.number} reconciled: superseded by ${list}`);
3079
+ } catch (err) {
3080
+ log(`#${issue.number} could not be reconciled (${errText(err)}) — retrying next tick`);
3081
+ }
3082
+ }
3083
+ }
3084
+ }
3085
+
2749
3086
  /**
2750
3087
  * Settles `claimed`/`running` rows left by a dead daemon process and, before
2751
3088
  * marking each one `orphaned`, salvages any dirty worktree.
@@ -2957,9 +3294,13 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
2957
3294
  // Its own principal, distinct from every worker slot. The orchestrator
2958
3295
  // reads the state directory and its briefs and must have no read or
2959
3296
  // write access to any run checkout — so `workspaceRoot` and the mirror
2960
- // are denied outright here, on top of the #127 tool-layer jail. Two
2961
- // layers because they fail differently: the jail refuses a structured
2962
- // tool call, the principal refuses the syscall `bash` would make.
3297
+ // are denied outright here. This is the ONLY mechanical file gate an
3298
+ // orchestrator gets: the tool-layer jail was deleted in 0.4.3 (#143)
3299
+ // because it could only be installed in sessions this daemon spawns, and
3300
+ // the principal refuses the syscall `bash` would make anyway, which the
3301
+ // jail never did. It is also conditional, and honestly so: on a host with
3302
+ // no isolating mechanism there is no principal and no gate, which is what
3303
+ // `status` reports as `unprotected`.
2963
3304
  // Same rule as a worker's, for the same reason: under `uid-pool` the
2964
3305
  // orchestrator is its own uid, so every tree it must reach lives outside
2965
3306
  // the 0700 private state directory. Its principal is in `conductor-runs`
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The pending-decision ledger's grammar, evaluation and digest (#136).
3
+ *
4
+ * A question the orchestrator sends its operator used to live in exactly one
5
+ * place: the model's context. A compaction, a restart, or a tick that ran long
6
+ * lost the question *and* the fact that one was owed — after which the session
7
+ * either asked again (the operator answers twice) or dropped it silently (the
8
+ * decision never lands, and nothing says a decision is outstanding).
9
+ *
10
+ * The store holds the rows. This module holds the two things around them that
11
+ * are not storage: what a machine-checkable precondition looks like, and how an
12
+ * open row is rendered into a tick prompt.
13
+ */
14
+
15
+ import type { DecisionRecord, Store, Tracker } from "./types.ts";
16
+
17
+ /**
18
+ * A precondition whose truth this package can check on its own.
19
+ *
20
+ * Exactly three kinds, deliberately. Each one is a question the tracker or npm
21
+ * already answers, so the row moves from "parked" to "act on this" without a
22
+ * human re-reading it. Anything richer — a label appearing, a workflow going
23
+ * green — is a follow-on issue rather than a grammar nobody validated.
24
+ */
25
+ export type DecisionCondition =
26
+ | { kind: "pr-merged"; url: string }
27
+ | { kind: "issue-closed"; issue: number }
28
+ | { kind: "npm-version"; spec: string };
29
+
30
+ /** `pkg@version`, including a scoped package (`@scope/pkg@1.2.3`). */
31
+ const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
32
+
33
+ /**
34
+ * Parse a raw condition, or `undefined` when it is not one of the three forms.
35
+ *
36
+ * Parsing on read rather than on write is what keeps a row this build does not
37
+ * understand *listable*: the raw string is stored verbatim, so a future grammar
38
+ * does not make an old row unloadable, and an unparseable one simply never
39
+ * reports itself met.
40
+ */
41
+ export function parseCondition(raw: string): DecisionCondition | undefined {
42
+ const text = raw.trim();
43
+ const at = text.indexOf(":");
44
+ if (at <= 0) return undefined;
45
+ const kind = text.slice(0, at);
46
+ const rest = text.slice(at + 1).trim();
47
+ if (rest.length === 0) return undefined;
48
+
49
+ if (kind === "pr-merged") {
50
+ return rest.startsWith("https://") ? { kind: "pr-merged", url: rest } : undefined;
51
+ }
52
+ if (kind === "issue-closed") {
53
+ if (!/^\d+$/.test(rest)) return undefined;
54
+ const issue = Number.parseInt(rest, 10);
55
+ return issue > 0 ? { kind: "issue-closed", issue } : undefined;
56
+ }
57
+ if (kind === "npm-version") {
58
+ return NPM_SPEC.test(rest) ? { kind: "npm-version", spec: rest } : undefined;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ /** The three accepted forms, for a refusal that can be acted on in one turn. */
64
+ export const CONDITION_FORMS = [
65
+ "pr-merged:https://github.com/owner/repo/pull/123",
66
+ "issue-closed:123",
67
+ "npm-version:omp-conductor@0.4.3",
68
+ ] as const;
69
+
70
+ /** Probe for `npm-version`, injectable so tests never reach the network. */
71
+ export type NpmProbe = (spec: string) => Promise<boolean>;
72
+
73
+ /**
74
+ * `npm view <spec> version` — exit 0 with non-empty output means the version is
75
+ * published. Bounded at 10 s because this runs inside a tick: a registry that
76
+ * hangs must cost one unevaluated condition, not the tick.
77
+ */
78
+ export const probeNpmVersion: NpmProbe = async (spec) => {
79
+ const proc = Bun.spawn(["npm", "view", spec, "version"], { stdout: "pipe", stderr: "ignore" });
80
+ const timer = setTimeout(() => {
81
+ proc.kill();
82
+ }, 10_000);
83
+ try {
84
+ const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
85
+ return code === 0 && text.trim().length > 0;
86
+ } catch {
87
+ return false;
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Check every open decision that carries a condition and has not met it yet.
95
+ *
96
+ * Errors are swallowed per row on purpose: this runs fire-and-forget beside a
97
+ * tick, and one deleted PR or one flaky `gh` call must not stop the rest of the
98
+ * pass. A condition that could not be checked is simply not met this time, and
99
+ * the next tick asks again for free.
100
+ *
101
+ * Returns the rows that just became met, so the caller can log what changed
102
+ * rather than a count.
103
+ */
104
+ export async function evaluateDecisionConditions(
105
+ store: Store,
106
+ project: string,
107
+ tracker: Tracker,
108
+ probeNpm: NpmProbe,
109
+ now: () => number,
110
+ ): Promise<DecisionRecord[]> {
111
+ const met: DecisionRecord[] = [];
112
+ for (const decision of store.openDecisions(project)) {
113
+ if (decision.condition === undefined || decision.conditionMetAt !== undefined) continue;
114
+ const condition = parseCondition(decision.condition);
115
+ if (condition === undefined) continue;
116
+ let satisfied = false;
117
+ try {
118
+ if (condition.kind === "pr-merged") {
119
+ satisfied = (await tracker.prState(condition.url)) === "merged";
120
+ } else if (condition.kind === "issue-closed") {
121
+ satisfied = (await tracker.issueState(condition.issue)) === "closed";
122
+ } else {
123
+ satisfied = await probeNpm(condition.spec);
124
+ }
125
+ } catch {
126
+ continue;
127
+ }
128
+ if (!satisfied) continue;
129
+ if (store.markDecisionConditionMet(decision.id, now())) {
130
+ met.push({ ...decision, conditionMetAt: now() });
131
+ }
132
+ }
133
+ return met;
134
+ }
135
+
136
+ /** Whole hours, then days — the granularity an operator reads a digest at. */
137
+ function age(since: number, now: number): string {
138
+ const minutes = Math.max(0, Math.round((now - since) / 60_000));
139
+ if (minutes < 60) return `${minutes}m`;
140
+ const hours = Math.round(minutes / 60);
141
+ return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
142
+ }
143
+
144
+ /**
145
+ * The open-decision block appended to a tick prompt, or `""` when nothing is
146
+ * owed.
147
+ *
148
+ * This is the half that makes the ledger worth having: the session is told what
149
+ * it still owes on every tick, from the store, so "what did I ask them?" stops
150
+ * being a memory question. A row whose condition has come true is flagged, not
151
+ * merely listed — that is the difference between a parked question and one to
152
+ * act on now.
153
+ */
154
+ export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
155
+ if (open.length === 0) return "";
156
+ const lines = [
157
+ `Open operator decisions (${open.length}) — resolve or withdraw each with omp-conductor decision resolve|withdraw <id>:`,
158
+ ];
159
+ for (const d of open) {
160
+ const flag = d.conditionMetAt === undefined ? "" : " [CONDITION MET — act on this now]";
161
+ lines.push(`- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}`);
162
+ }
163
+ return lines.join("\n");
164
+ }