omp-conductor 0.4.2 → 0.4.4

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.
@@ -74,6 +74,8 @@ import {
74
74
  type ResolvedGrants,
75
75
  type Store,
76
76
  } from "./types.ts";
77
+ import { formatDecisionDigest } from "./decisions.ts";
78
+ import type { RunRecord } from "./types.ts";
77
79
  import { dbPath, openStore } from "./store.ts";
78
80
 
79
81
  /** The activation file. Absent means "this is not an orchestrator session". */
@@ -333,10 +335,29 @@ export function defaultTickMessage(
333
335
  * `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
334
336
  * the point of use.
335
337
  */
338
+ /**
339
+ * One line naming what the daemon recovered without asking (#132).
340
+ *
341
+ * The orchestrator used to write this paragraph by re-deriving it from the run
342
+ * rows every tick. Undefined when nothing was recovered: a line reading "0" is
343
+ * one nobody reads on the day it says 4.
344
+ */
345
+ export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
346
+ if (recovered.length === 0) return undefined;
347
+ const named = recovered
348
+ .slice(0, 5)
349
+ .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
350
+ .join(", ");
351
+ const rest = recovered.length > 5 ? `, +${recovered.length - 5} more` : "";
352
+ return `Auto-recovered since last tick: ${recovered.length} (${named}${rest}) — already handled, do not re-triage these.`;
353
+ }
354
+
336
355
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
337
356
  material: "Report material events per your brief.",
338
357
  escalations:
339
358
  "Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
359
+ decisions:
360
+ "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
340
361
  };
341
362
 
342
363
  /**
@@ -1154,6 +1175,18 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1154
1175
  now - FRICTION_COOLDOWN_MS,
1155
1176
  );
1156
1177
  if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
1178
+ // What the session still owes its operator, read from the ledger rather
1179
+ // than from what it remembers asking (#136). Appended every tick,
1180
+ // because the whole failure was a question surviving in context only.
1181
+ const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
1182
+ if (decisions.length > 0) content = `${content}\n${decisions}`;
1183
+ // What the daemon already fixed, so the session stops re-deriving that
1184
+ // paragraph on every tick (#132). Two intervals wide rather than one: a
1185
+ // tick that ran long must not drop the window it was meant to report.
1186
+ const recovered = recoveryDigestLine(
1187
+ frictionStore.recoveredSince(scope.projectName, now - 2 * config.intervalSeconds * 1_000),
1188
+ );
1189
+ if (recovered !== undefined) content = `${content}\n${recovered}`;
1157
1190
  } catch (err) {
1158
1191
  frictionStore?.close();
1159
1192
  frictionStore = undefined;
package/src/plugin.ts CHANGED
@@ -13,13 +13,11 @@
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { dirname, isAbsolute } from "node:path";
15
15
  import {
16
- checkBrief,
17
- formatBriefStatus,
16
+ formatBriefReport,
18
17
  formatMigrateResult,
19
18
  inspectBriefLayout,
20
19
  migrateToPolicy,
21
20
  repairPolicyBannerCrumbs,
22
- writeMergedBrief,
23
21
  } from "./brief-upgrade.ts";
24
22
  import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
25
23
  import { mechanismSatisfies, probeHost } from "./credentials.ts";
@@ -1424,11 +1422,7 @@ export default function conductorPlugin(pi: PluginApi): void {
1424
1422
  }
1425
1423
  if (layout.kind === "overlay") {
1426
1424
  ctx.ui.notify(
1427
- formatBriefStatus(path, {
1428
- kind: "overlay",
1429
- policyPath: layout.policyPath,
1430
- orchestratorPath: layout.orchestratorPath,
1431
- }),
1425
+ formatBriefReport(path, layout, []),
1432
1426
  "info",
1433
1427
  );
1434
1428
  const repair = await ctx.ui.confirm(
@@ -1473,18 +1467,13 @@ export default function conductorPlugin(pi: PluginApi): void {
1473
1467
  }
1474
1468
  break;
1475
1469
  }
1476
- const status = checkBrief(readFileSync(path, "utf8"), rendered);
1477
- ctx.ui.notify(formatBriefStatus(path, status), "warning");
1478
- if (status.kind === "mergeable") {
1479
- const apply = await ctx.ui.confirm(
1480
- "Upgrade the brief?",
1481
- "Replace the half above the YOURS TO EDIT banner with the one this version ships? Everything below the banner is kept exactly as it is, and the current file is backed up first.",
1482
- );
1483
- if (apply) {
1484
- const backup = writeMergedBrief(path, status.merged);
1485
- ctx.ui.notify(`Brief upgraded. Previous version kept at ${backup}.`, "info");
1486
- }
1487
- }
1470
+ // Hand-written: the plugin no longer merges single-file briefs
1471
+ // (#131). There is no banner, so nothing here can tell which lines
1472
+ // are the operator's — a retrofit has to name the cut first.
1473
+ ctx.ui.notify(
1474
+ `Hand-written brief at ${path} — run: omp-conductor brief-upgrade --retrofit (then --migrate). The plugin no longer merges single-file briefs.`,
1475
+ "warning",
1476
+ );
1488
1477
  break;
1489
1478
  }
1490
1479
 
@@ -23,14 +23,13 @@
23
23
  import { connect } from "node:net";
24
24
 
25
25
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
26
- import type { OrchestratorJail, OrchestratorRefusal } from "./confinement.ts";
26
+
27
27
  import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
28
28
 
29
29
  /**
30
30
  * Everything the child needs to build the session. Plain JSON on purpose:
31
- * callbacks (`onReleaseBlocked`, the confinement audit sink) cannot cross a
32
- * process boundary, so they become messages instead — see
33
- * {@link HostToParent}.
31
+ * callbacks (`onReleaseBlocked`) cannot cross a process boundary, so they
32
+ * become messages instead — see {@link HostToParent}.
34
33
  */
35
34
  export interface SessionHostSpec {
36
35
  socket: string;
@@ -40,19 +39,13 @@ export interface SessionHostSpec {
40
39
  model?: string;
41
40
  resume?: boolean;
42
41
  releaseGrants?: ResolvedGrants;
43
- /**
44
- * Resolved by the *parent*, never by this process. `orchestratorJailFromConfig`
45
- * reads the conductor config, and an isolated child cannot — a jail that
46
- * silently fell back to defaults here would widen the very gate #127 closed.
47
- */
48
- orchestratorJail?: OrchestratorJail;
49
42
  /**
50
43
  * The conductor verb socket this session may call mutations on (#126).
51
44
  *
52
45
  * Carried in the spec rather than read from the environment by the child,
53
- * for the same reason `orchestratorJail` is resolved by the parent: the
54
- * child deciding *which* socket it owns is the child deciding which run it
55
- * is, and that is the question the transport exists to stop it answering.
46
+ * for the same reason the grants are: the child deciding *which* socket it
47
+ * owns is the child deciding which run it is, and that is the question the
48
+ * transport exists to stop it answering.
56
49
  * Absent, the verbs are registered and every one of them fails closed.
57
50
  */
58
51
  verbSocketPath?: string;
@@ -71,8 +64,7 @@ export type HostToParent =
71
64
  | { t: "event"; event: unknown }
72
65
  | { t: "session-file"; path: string }
73
66
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
74
- | { t: "release-blocked"; shape: ReleaseShape }
75
- | { t: "confinement-refusal"; refusal: OrchestratorRefusal };
67
+ | { t: "release-blocked"; shape: ReleaseShape };
76
68
 
77
69
  /**
78
70
  * Depth at which a harness event stops being copied for the wire.
@@ -195,17 +187,13 @@ export async function runSessionHost(
195
187
  ...(spec.model === undefined ? {} : { model: spec.model }),
196
188
  ...(spec.resume === undefined ? {} : { resume: spec.resume }),
197
189
  ...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
198
- ...(spec.orchestratorJail === undefined ? {} : { orchestratorJail: spec.orchestratorJail }),
199
190
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
200
- // Both audit sinks live in the daemon's state directory, which this
201
- // process may not be able to write and must not be trusted to. They
202
- // become messages; the parent performs the durable write.
191
+ // The release audit lives in the daemon's state directory, which this
192
+ // process may not be able to write and must not be trusted to. It
193
+ // becomes a message; the parent performs the durable write.
203
194
  onReleaseBlocked: (shape) => {
204
195
  send({ t: "release-blocked", shape });
205
196
  },
206
- onConfinementRefusal: (refusal) => {
207
- send({ t: "confinement-refusal", refusal });
208
- },
209
197
  });
210
198
  } catch (err) {
211
199
  send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
package/src/setup.ts CHANGED
@@ -186,12 +186,18 @@ export const SETUP_DEFAULTS = {
186
186
  } as const;
187
187
 
188
188
  /**
189
- * The two report scopes as the operator meets them, described once. The wizard
189
+ * The three report scopes as the operator meets them, described once. The wizard
190
190
  * shows these labels, the plan summary quotes the description, and the rendered
191
- * brief spells the same two options out — so "material" cannot come to mean one
192
- * thing in the dialog and another in the session that has to honour it.
191
+ * brief spells the same three options out — so "material" cannot come to mean
192
+ * one thing in the dialog and another in the session that has to honour it.
193
193
  */
194
194
  export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
195
+ {
196
+ scope: "decisions",
197
+ label: "Decisions interrupt, rest batches",
198
+ description:
199
+ "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the tick report",
200
+ },
195
201
  {
196
202
  scope: "material",
197
203
  label: "Material events",
@@ -204,6 +210,18 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
204
210
  },
205
211
  ];
206
212
 
213
+ /**
214
+ * What a *fresh* interview opens on: the first choice above, by construction, so
215
+ * reordering that list moves the wizard's cursor with it.
216
+ *
217
+ * Deliberately not {@link DEFAULT_REPORT_SCOPE}. That one answers "a config on
218
+ * disk with no `reporting` key" and must stay `material` forever, because
219
+ * changing it would turn a running fleet's volume down on upgrade. This one
220
+ * answers "an operator being asked the question for the first time", where the
221
+ * recommended answer is the useful one — and they see it, and confirm it.
222
+ */
223
+ export const SETUP_DEFAULT_REPORT_SCOPE: ReportScope = REPORT_SCOPE_CHOICES[0]!.scope;
224
+
207
225
  /**
208
226
  * What each precondition value means to the operator being asked about it, in
209
227
  * their words rather than the gate's.
@@ -625,7 +643,7 @@ export function defaultAnswers(projectName: string): SetupAnswers {
625
643
  policy: clonePolicy(SETUP_DEFAULTS.policy),
626
644
  credentials: { ...SETUP_DEFAULTS.credentials },
627
645
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
628
- reportScope: DEFAULT_REPORT_SCOPE,
646
+ reportScope: SETUP_DEFAULT_REPORT_SCOPE,
629
647
  writeOrchestratorBrief: false,
630
648
  };
631
649
  }
package/src/store.ts CHANGED
@@ -14,8 +14,12 @@ import { mkdirSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
 
16
16
  import { stateDir } from "./config.ts";
17
- import { DEFAULT_CAPS } from "./types.ts";
17
+ import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
18
18
  import type {
19
+ DecisionDraft,
20
+ FailureClass,
21
+ DecisionRecord,
22
+ DecisionState,
19
23
  DispatchSummary,
20
24
  FrictionAdmissionReason,
21
25
  FrictionKind,
@@ -27,6 +31,7 @@ import type {
27
31
  ReportEnqueue,
28
32
  ReportKind,
29
33
  ReportRecord,
34
+ RecoveryAction,
30
35
  RunRecord,
31
36
  RunState,
32
37
  SessionRole,
@@ -105,6 +110,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
105
110
  endedAt: true,
106
111
  lastError: true,
107
112
  settlementFlags: true,
113
+ failureClass: true,
114
+ recoveryAction: true,
115
+ recoveredAt: true,
108
116
  };
109
117
 
110
118
  /** Everything SQLite will accept from us. */
@@ -133,6 +141,9 @@ interface RunRow {
133
141
  endedAt: number | null;
134
142
  lastError: string | null;
135
143
  settlementFlags: string | null;
144
+ failureClass: string | null;
145
+ recoveryAction: string | null;
146
+ recoveredAt: number | null;
136
147
  }
137
148
 
138
149
  interface FrictionRollupRow {
@@ -216,7 +227,10 @@ CREATE TABLE IF NOT EXISTS runs (
216
227
  startedAt INTEGER NOT NULL,
217
228
  endedAt INTEGER,
218
229
  lastError TEXT,
219
- settlementFlags TEXT
230
+ settlementFlags TEXT,
231
+ failureClass TEXT,
232
+ recoveryAction TEXT,
233
+ recoveredAt INTEGER
220
234
  );
221
235
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
222
236
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -317,6 +331,34 @@ CREATE TABLE IF NOT EXISTS merge_locks (
317
331
  prUrl TEXT NOT NULL,
318
332
  at INTEGER NOT NULL
319
333
  );
334
+
335
+ -- Questions the orchestrator has put to its operator, and their answers (#136).
336
+ --
337
+ -- The reason this is a table and not the session's memory: a question lived only
338
+ -- in the model's context, so a compaction, a restart or a tick that ran long
339
+ -- lost both the question and the fact that it was owed one. The session then
340
+ -- either re-asked (the operator answers twice) or silently dropped it (the
341
+ -- decision never lands, and nothing anywhere says a decision is outstanding).
342
+ --
343
+ -- The 'condition' column is a machine-checkable precondition for the answer
344
+ -- becoming actionable -- a PR merging, an issue closing, a version on npm --
345
+ -- stored as the raw string an operator or session wrote, parsed on read. Once
346
+ -- met, 'conditionMetAt' is what turns a parked question into one the tick digest
347
+ -- pushes at the session.
348
+ CREATE TABLE IF NOT EXISTS decisions (
349
+ id TEXT PRIMARY KEY,
350
+ project TEXT NOT NULL,
351
+ question TEXT NOT NULL,
352
+ blocks TEXT,
353
+ askedAt INTEGER NOT NULL,
354
+ expiresAt INTEGER NOT NULL,
355
+ condition TEXT,
356
+ conditionMetAt INTEGER,
357
+ state TEXT NOT NULL,
358
+ resolvedAt INTEGER,
359
+ resolution TEXT
360
+ );
361
+ CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
320
362
  `;
321
363
 
322
364
  /**
@@ -392,6 +434,9 @@ function toRecord(row: RunRow): RunRecord {
392
434
  const flags = toSettlementFlags(row.settlementFlags);
393
435
  if (flags !== undefined) record.settlementFlags = flags;
394
436
  }
437
+ if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
438
+ if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
439
+ if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
395
440
  return record;
396
441
  }
397
442
 
@@ -420,6 +465,38 @@ function toReport(row: ReportRow): ReportRecord {
420
465
  return record;
421
466
  }
422
467
 
468
+ interface DecisionRow {
469
+ id: string;
470
+ project: string;
471
+ question: string;
472
+ blocks: string | null;
473
+ askedAt: number;
474
+ expiresAt: number;
475
+ condition: string | null;
476
+ conditionMetAt: number | null;
477
+ state: string;
478
+ resolvedAt: number | null;
479
+ resolution: string | null;
480
+ }
481
+
482
+ /** Same NULL-to-absent contract as {@link toReport}. */
483
+ function toDecision(row: DecisionRow): DecisionRecord {
484
+ const record: DecisionRecord = {
485
+ id: row.id,
486
+ project: row.project,
487
+ question: row.question,
488
+ askedAt: row.askedAt,
489
+ expiresAt: row.expiresAt,
490
+ state: row.state as DecisionState,
491
+ };
492
+ if (row.blocks !== null) record.blocks = row.blocks;
493
+ if (row.condition !== null) record.condition = row.condition;
494
+ if (row.conditionMetAt !== null) record.conditionMetAt = row.conditionMetAt;
495
+ if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
496
+ if (row.resolution !== null) record.resolution = row.resolution;
497
+ return record;
498
+ }
499
+
423
500
  /**
424
501
  * Long enough that a collision is not a thing anyone has to handle, short
425
502
  * enough that an operator can compare it against the id printed in a Telegram
@@ -587,6 +664,19 @@ export function openStore(dbPath: string): Store {
587
664
  if (!columns.some((column) => column.name === "settlementFlags")) {
588
665
  db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
589
666
  }
667
+ // Every row written before #132 is unclassified, and NULL is the honest
668
+ // reading of that: the budget counters below deliberately still count an
669
+ // unclassified terminal row exactly as this release's predecessor did, so an
670
+ // upgrade never silently re-opens a budget an operator had already spent.
671
+ for (const [name, type] of [
672
+ ["failureClass", "TEXT"],
673
+ ["recoveryAction", "TEXT"],
674
+ ["recoveredAt", "INTEGER"],
675
+ ] as const) {
676
+ if (!columns.some((column) => column.name === name)) {
677
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
678
+ }
679
+ }
590
680
 
591
681
  const insertRun = db.query<unknown, SqlValue[]>(
592
682
  `INSERT INTO runs (
@@ -626,13 +716,53 @@ export function openStore(dbPath: string): Store {
626
716
  const countAttempts = db.query<{ n: number }, [string, number]>(
627
717
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
628
718
  );
719
+ // The classes excluded here are the ones whose cause is not the work (#132): a
720
+ // runner that never produced a verdict, a row whose PR had already merged, a
721
+ // kill that came from a daemon restart rather than from a turn ceiling. On this
722
+ // fleet #350 spent its whole continuation budget on causes like those and then
723
+ // sat in admission hold for six dispatch cycles. An unclassified row (NULL) is
724
+ // counted exactly as before, so upgrading changes no existing budget.
629
725
  const countFailures = db.query<{ n: number }, [string, number]>(
630
726
  `SELECT COUNT(*) AS n FROM runs
631
- WHERE project = ? AND issue = ? AND state = 'failed'`,
727
+ WHERE project = ? AND issue = ? AND state = 'failed'
728
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck'))`,
632
729
  );
633
730
  const countContinuations = db.query<{ n: number }, [string, number]>(
634
731
  `SELECT COUNT(*) AS n FROM runs
635
- WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')`,
732
+ WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
733
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck'))`,
734
+ );
735
+ // Newest first, and bounded: every row this returns costs `gh` calls to gather
736
+ // facts for, so a fleet with a long unclassified history classifies over
737
+ // several ticks rather than spending one tick's budget on all of it.
738
+ //
739
+ // Classified-but-unrecovered rows are included, not just unclassified ones: a
740
+ // recovery whose tracker write failed has to come back, or the log line saying
741
+ // "retrying next tick" is a lie and the row is stranded with a class and no
742
+ // action. `hold` is excluded because it is *recorded only* by design — its
743
+ // `recoveredAt` stays NULL forever, and re-offering it would spin the sweep.
744
+ const selectUnclassified = db.query<RunRow, [string, number]>(
745
+ `SELECT * FROM runs
746
+ WHERE project = ?
747
+ AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
748
+ AND (
749
+ failureClass IS NULL
750
+ OR (recoveredAt IS NULL AND recoveryAction IN ('settle', 'continue', 'requeue', 'rerun-checks'))
751
+ )
752
+ ORDER BY startedAt DESC, rowid DESC
753
+ LIMIT ?`,
754
+ );
755
+ const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
756
+ `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
757
+ WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
758
+ AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
759
+ GROUP BY failureClass
760
+ ORDER BY n DESC, failureClass ASC`,
761
+ );
762
+ const selectRecoveredSince = db.query<RunRow, [string, number]>(
763
+ `SELECT * FROM runs
764
+ WHERE project = ? AND recoveredAt IS NOT NULL AND recoveredAt >= ?
765
+ ORDER BY recoveredAt DESC, rowid DESC`,
636
766
  );
637
767
  // Salvage state that still describes an issue's present, so an operator is
638
768
  // shown a preserved WIP tip exactly while it is the thing a re-claim would
@@ -777,6 +907,39 @@ export function openStore(dbPath: string): Store {
777
907
  ORDER BY createdAt ASC, rowid ASC`,
778
908
  );
779
909
 
910
+ const insertDecision = db.query<never, [string, string, string, SqlValue, number, number, SqlValue, string]>(
911
+ `INSERT INTO decisions
912
+ (id, project, question, blocks, askedAt, expiresAt, condition, state)
913
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
914
+ );
915
+ const selectDecision = db.query<DecisionRow, [string]>(`SELECT * FROM decisions WHERE id = ?`);
916
+ const selectOpenDecisions = db.query<DecisionRow, [string]>(
917
+ `SELECT * FROM decisions
918
+ WHERE project = ? AND state = 'open'
919
+ ORDER BY askedAt ASC, rowid ASC`,
920
+ );
921
+ const selectDueDecisions = db.query<DecisionRow, [string, number]>(
922
+ `SELECT * FROM decisions
923
+ WHERE project = ? AND state = 'open' AND expiresAt <= ?
924
+ ORDER BY askedAt ASC, rowid ASC`,
925
+ );
926
+ // `state = 'open'` in the WHERE is the double-resolve guard: the second call
927
+ // changes no rows and returns false rather than overwriting the first answer.
928
+ const resolveDecisionRow = db.query<never, [string, string, number, string]>(
929
+ `UPDATE decisions SET state = ?, resolution = ?, resolvedAt = ?
930
+ WHERE id = ? AND state = 'open'`,
931
+ );
932
+ const expireDecisionRows = db.query<never, [number, string, number]>(
933
+ `UPDATE decisions SET state = 'expired', resolvedAt = ?, resolution = 'expired unanswered'
934
+ WHERE project = ? AND state = 'open' AND expiresAt <= ?`,
935
+ );
936
+ // Set once: a condition that flickers must not keep moving its own timestamp,
937
+ // or the digest's "act on this now" would reset to looking new every tick.
938
+ const markConditionMetRow = db.query<never, [number, string]>(
939
+ `UPDATE decisions SET conditionMetAt = ?
940
+ WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
941
+ );
942
+
780
943
  const insertVerbLedger = db.query<unknown, SqlValue[]>(
781
944
  `INSERT INTO verb_ledger
782
945
  (id, project, runId, issue, verb, role, args, decision, refusal, detail, sha, at)
@@ -1152,6 +1315,69 @@ export function openStore(dbPath: string): Store {
1152
1315
  return selectOpenReports.all(project).map(toReport);
1153
1316
  },
1154
1317
 
1318
+ runsNeedingClassification(project: string, limit = 20): RunRecord[] {
1319
+ return selectUnclassified.all(project, limit).map(toRecord);
1320
+ },
1321
+
1322
+ failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
1323
+ return selectFailureClassCounts
1324
+ .all(project)
1325
+ .map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
1326
+ },
1327
+
1328
+ recoveredSince(project: string, since: number): RunRecord[] {
1329
+ return selectRecoveredSince.all(project, since).map(toRecord);
1330
+ },
1331
+
1332
+ createDecision(draft: DecisionDraft): DecisionRecord {
1333
+ const record: DecisionRecord = {
1334
+ id: crypto.randomUUID(),
1335
+ project: draft.project,
1336
+ question: draft.question,
1337
+ askedAt: draft.at,
1338
+ expiresAt: draft.at + DECISION_TTL_MS,
1339
+ state: "open",
1340
+ };
1341
+ if (draft.blocks !== undefined) record.blocks = draft.blocks;
1342
+ if (draft.condition !== undefined) record.condition = draft.condition;
1343
+ insertDecision.run(
1344
+ record.id,
1345
+ record.project,
1346
+ record.question,
1347
+ toSql(record.blocks),
1348
+ record.askedAt,
1349
+ record.expiresAt,
1350
+ toSql(record.condition),
1351
+ record.state,
1352
+ );
1353
+ return record;
1354
+ },
1355
+
1356
+ openDecisions(project: string): DecisionRecord[] {
1357
+ return selectOpenDecisions.all(project).map(toDecision);
1358
+ },
1359
+
1360
+ resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
1361
+ return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
1362
+ },
1363
+
1364
+ markDecisionConditionMet(id: string, at: number): boolean {
1365
+ return markConditionMetRow.run(at, id).changes > 0;
1366
+ },
1367
+
1368
+ expireDueDecisions(project: string, now: number): DecisionRecord[] {
1369
+ const due = selectDueDecisions.all(project, now);
1370
+ if (due.length === 0) return [];
1371
+ expireDecisionRows.run(now, project, now);
1372
+ // Re-read for the same reason `recoverSendingReports` does: the caller
1373
+ // renders these, and a row that still says `open` while the ledger says
1374
+ // `expired` is the disagreement this table exists to remove.
1375
+ return due
1376
+ .map((row) => selectDecision.get(row.id))
1377
+ .filter((row): row is DecisionRow => row !== null)
1378
+ .map(toDecision);
1379
+ },
1380
+
1155
1381
  appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry {
1156
1382
  const entry: VerbLedgerEntry = { ...draft, id: crypto.randomUUID(), at: Date.now() };
1157
1383
  insertVerbLedger.run(