@dzhechkov/harness-core 0.8.36 → 0.8.38

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 (112) hide show
  1. package/.dz-manifest.json +216 -76
  2. package/README.md +349 -8
  3. package/dist/agentdb-index.d.ts +87 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +416 -57
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +19 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +187 -36
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-rollouts.d.ts +118 -0
  12. package/dist/codex-rollouts.d.ts.map +1 -0
  13. package/dist/codex-rollouts.js +297 -0
  14. package/dist/codex-rollouts.js.map +1 -0
  15. package/dist/cost-ledger.d.ts +56 -4
  16. package/dist/cost-ledger.d.ts.map +1 -1
  17. package/dist/cost-ledger.js +176 -20
  18. package/dist/cost-ledger.js.map +1 -1
  19. package/dist/cross-family-control.d.ts +380 -0
  20. package/dist/cross-family-control.d.ts.map +1 -0
  21. package/dist/cross-family-control.js +848 -0
  22. package/dist/cross-family-control.js.map +1 -0
  23. package/dist/debt-ratchet.d.ts +53 -0
  24. package/dist/debt-ratchet.d.ts.map +1 -0
  25. package/dist/debt-ratchet.js +107 -0
  26. package/dist/debt-ratchet.js.map +1 -0
  27. package/dist/embedding-config.d.ts +42 -0
  28. package/dist/embedding-config.d.ts.map +1 -1
  29. package/dist/embedding-config.js +106 -10
  30. package/dist/embedding-config.js.map +1 -1
  31. package/dist/feature-adr-checkpoints.d.ts +6 -0
  32. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  33. package/dist/feature-adr-checkpoints.js +29 -0
  34. package/dist/feature-adr-checkpoints.js.map +1 -1
  35. package/dist/feature-adr-decision-recall.d.ts +2 -2
  36. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  37. package/dist/feature-adr-decision-recall.js +5 -3
  38. package/dist/feature-adr-decision-recall.js.map +1 -1
  39. package/dist/feature-adr-envelope.d.ts +96 -0
  40. package/dist/feature-adr-envelope.d.ts.map +1 -0
  41. package/dist/feature-adr-envelope.js +183 -0
  42. package/dist/feature-adr-envelope.js.map +1 -0
  43. package/dist/feature-adr-routing.d.ts +64 -0
  44. package/dist/feature-adr-routing.d.ts.map +1 -1
  45. package/dist/feature-adr-routing.js +133 -3
  46. package/dist/feature-adr-routing.js.map +1 -1
  47. package/dist/feature-adr-stage-canon.d.ts +79 -0
  48. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  49. package/dist/feature-adr-stage-canon.js +117 -0
  50. package/dist/feature-adr-stage-canon.js.map +1 -0
  51. package/dist/index.d.ts +21 -9
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +15 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/loop-blobs.generated.js +4 -4
  56. package/dist/loop-blobs.generated.js.map +1 -1
  57. package/dist/mutation-gate.d.ts +51 -0
  58. package/dist/mutation-gate.d.ts.map +1 -1
  59. package/dist/mutation-gate.js +295 -0
  60. package/dist/mutation-gate.js.map +1 -1
  61. package/dist/qe-bridge.d.ts +8 -0
  62. package/dist/qe-bridge.d.ts.map +1 -1
  63. package/dist/qe-bridge.js +4 -2
  64. package/dist/qe-bridge.js.map +1 -1
  65. package/dist/qe-findings.d.ts +107 -0
  66. package/dist/qe-findings.d.ts.map +1 -0
  67. package/dist/qe-findings.js +417 -0
  68. package/dist/qe-findings.js.map +1 -0
  69. package/dist/recap.d.ts +1 -1
  70. package/dist/recap.d.ts.map +1 -1
  71. package/dist/recap.js +4 -2
  72. package/dist/recap.js.map +1 -1
  73. package/dist/review-cost.d.ts +51 -0
  74. package/dist/review-cost.d.ts.map +1 -0
  75. package/dist/review-cost.js +110 -0
  76. package/dist/review-cost.js.map +1 -0
  77. package/dist/round.d.ts +207 -1
  78. package/dist/round.d.ts.map +1 -1
  79. package/dist/round.js +321 -4
  80. package/dist/round.js.map +1 -1
  81. package/dist/run-records.d.ts +97 -0
  82. package/dist/run-records.d.ts.map +1 -1
  83. package/dist/run-records.js +336 -2
  84. package/dist/run-records.js.map +1 -1
  85. package/dist/score.d.ts +44 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +78 -5
  88. package/dist/score.js.map +1 -1
  89. package/package.json +1 -1
  90. package/sbom.json +425 -75
  91. package/src/agentdb-index.ts +423 -60
  92. package/src/apply-leg.ts +187 -36
  93. package/src/codex-rollouts.ts +374 -0
  94. package/src/cost-ledger.ts +232 -24
  95. package/src/cross-family-control.ts +1038 -0
  96. package/src/debt-ratchet.ts +143 -0
  97. package/src/embedding-config.ts +131 -10
  98. package/src/feature-adr-checkpoints.ts +29 -0
  99. package/src/feature-adr-decision-recall.ts +6 -4
  100. package/src/feature-adr-envelope.ts +242 -0
  101. package/src/feature-adr-routing.ts +150 -3
  102. package/src/feature-adr-stage-canon.ts +141 -0
  103. package/src/index.ts +65 -6
  104. package/src/loop-blobs.generated.ts +4 -4
  105. package/src/mutation-gate.ts +316 -0
  106. package/src/qe-bridge.ts +12 -2
  107. package/src/qe-findings.ts +463 -0
  108. package/src/recap.ts +10 -3
  109. package/src/review-cost.ts +139 -0
  110. package/src/round.ts +481 -6
  111. package/src/run-records.ts +388 -2
  112. package/src/score.ts +115 -6
package/src/round.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  * filesystem or process API; the CLI owns `.dz/rounds/` and the witnessed ledger writer.
5
5
  */
6
6
 
7
+ import { validateExperimentEnvelope } from './feature-adr-envelope.js';
8
+ import type { QeBridgeCost } from './review-cost.js';
9
+
7
10
  const ROUND_OUTCOMES = ['shipped', 'refuted', 'blocked', 'abandoned'] as const;
8
11
  type RoundOutcome = typeof ROUND_OUTCOMES[number];
9
12
 
@@ -18,6 +21,15 @@ export interface RoundState {
18
21
  readonly run?: string;
19
22
  readonly recalled: readonly string[];
20
23
  readonly execs?: readonly RoundExecState[];
24
+ /** experiment-envelope FR-3(в): the envelope the pipeline built after its Step-0 router, carried
25
+ * unchanged through `round open --envelope` into `closeRound`'s ledger row. Opaque here (never
26
+ * interpreted by round.ts) — validated once, at `openRound`, and trusted from then on. */
27
+ readonly envelope?: unknown;
28
+ /** experiment-instrument FR-1/FR-3 (ADR-001): minted once at `openRound` — `--task` when given
29
+ * (validated), else `slug@startedAt`. Optional at the TYPE level, not because a fresh round ever
30
+ * omits it, but because a state file written before this feature landed has no such key on disk;
31
+ * `closeRound` derives the same default for that legacy case and names the source (A4/A5). */
32
+ readonly taskId?: string;
21
33
  }
22
34
 
23
35
  export interface RoundExecState {
@@ -39,7 +51,10 @@ export interface RoundLedgerRow {
39
51
  readonly minutes: number | null;
40
52
  readonly agents: number | null;
41
53
  readonly tokens: number | null;
42
- readonly grade: null;
54
+ /** measurement-integrity FR-7: was always `null` before this feature — `shipped|refuted` now
55
+ * requires a real grade; `blocked|abandoned` still writes `null` (a review that never finished has
56
+ * nothing to grade). */
57
+ readonly grade: string | null;
43
58
  readonly outcome: RoundOutcome;
44
59
  readonly reason: string | null;
45
60
  readonly round: number;
@@ -48,9 +63,123 @@ export interface RoundLedgerRow {
48
63
  readonly note: string;
49
64
  readonly date: null;
50
65
  readonly costIn?: 'stages';
66
+ /** experiment-envelope FR-3(в): copied verbatim from the state that closed this round, when present. */
67
+ readonly envelope?: unknown;
51
68
  /** round-state-lock (lead edit after Codex re-review): identity of the state instance this row
52
69
  * closes — lets a retried `close` detect its own earlier row regardless of the clock. */
53
70
  readonly stateId?: string;
71
+ /** measurement-integrity FR-7: present ONLY when `reviewer` was filled from the qe-bridge sidecar
72
+ * (no explicit `--reviewer`) — `elapsedMs / 60000`, rounded to 1 decimal. review-cost-ledger
73
+ * fix-round-1 #1/#2 (ADR-001 п.2 amended): also present when an EXPLICIT `--reviewer` AGREES with
74
+ * the sidecar's own `gradedBy` (`reviewSource:'flag+qe-bridge'` below names that case). */
75
+ readonly reviewMinutes?: number;
76
+ /** measurement-integrity FR-7: present ONLY alongside `reviewMinutes` — names where `reviewer` and
77
+ * `reviewMinutes` came from, so a reader never confuses a sidecar-sourced figure for a flag.
78
+ * review-cost-ledger fix-round-1 #1/#2 (ADR-001 п.2 amended): `'qe-bridge'` when `reviewer` was
79
+ * FILLED from the sidecar (no `--reviewer` flag); `'flag+qe-bridge'` when an explicit `--reviewer`
80
+ * AGREES with the sidecar's own `gradedBy` (case-insensitive `family:model` equality, or a
81
+ * family-only flag matching the sidecar's family) — the flag's IDENTITY is confirmed by the
82
+ * sidecar, so its price/minutes are attributed too. A DISAGREEING explicit `--reviewer` is refused
83
+ * outright (`closeRound` never reaches row-building for that case) — never silently ignored. */
84
+ readonly reviewSource?: 'qe-bridge' | 'flag+qe-bridge';
85
+ /** experiment-instrument FR-1/FR-3 (ADR-001): the task identity this round's state carried (or, for
86
+ * a legacy state with no `taskId` on disk, the same `slug@startedAt` default `openRound` would have
87
+ * minted — always present, never null: a round always has a slug and a startedAt). */
88
+ readonly taskId?: string;
89
+ /** experiment-instrument A4/A5: present ONLY when `taskId` above was DERIVED here rather than read
90
+ * from the state file — i.e. the state predates this feature. A fresh round never carries this. */
91
+ readonly taskIdSource?: 'derived-legacy';
92
+ /** experiment-instrument FR-3/A2: the git sha `dz round close` resolved at close time (cli-only —
93
+ * the core never shells out), present only for a FINISHED outcome (`shipped|refuted`). */
94
+ readonly shipSha?: string | null;
95
+ /** experiment-instrument FR-3/A2: same timestamp as this row's own close (`closedAt`), present only
96
+ * alongside `shipSha` — the anchor "when was this shipped", not a duplicate of `date`. */
97
+ readonly shippedAt?: string;
98
+ /** experiment-instrument A2: present only when `shipSha` is `null` for a finished outcome — why the
99
+ * sha could not be resolved (e.g. "not a git repository", "git failed: …"). */
100
+ readonly shipShaReason?: string;
101
+ /** experiment-instrument r1-5 (Codex r1 HIGH #5, ADR-001 amended): `HEAD` alone does not identify
102
+ * what was actually shipped when the worktree carries uncommitted changes — this hub's own tree is
103
+ * ALWAYS dirty with unrelated files, so a clean-tree requirement was rejected; instead the row
104
+ * names the limit. `true` when `git status --porcelain` was non-empty at close time, `false` when
105
+ * empty, present only for a FINISHED outcome, alongside `shipSha`. */
106
+ readonly shipTreeDirty?: boolean;
107
+ /** experiment-instrument r1-5: present ONLY when `shipTreeDirty` above could not be determined (the
108
+ * `git status --porcelain` probe itself failed) — the same "never a bare unexplained gap" rule
109
+ * `shipShaReason` already follows, for the sibling probe. */
110
+ readonly shipTreeDirtyReason?: string;
111
+ /** review-cost-ledger FR-2/A2 (ADR-001 п.1-2): the qe-bridge reviewer's OWN price, from the stdout
112
+ * sidecar — present only when {@link reviewerCostSource} is `'qe-bridge-stdout'` (a usable price
113
+ * was found) or `'unavailable'` (present with the reviewer known but the price is not — `null` in
114
+ * that case, never omitted: NFR-4, "nothing is guessed"). */
115
+ readonly reviewerCostUsd?: number | null;
116
+ /** review-cost-ledger FR-2/A2: sum of the four token components below, present only alongside a
117
+ * successful {@link reviewerCostUsd} (`reviewerCostSource:'qe-bridge-stdout'`). fix-round-1 #5
118
+ * (Codex r1 HIGH #5): `null` — never a silently-zeroed sum — when the sidecar's own
119
+ * `tokens.tokensPartial` was `true` (at least one component was missing from the source JSON): an
120
+ * incomplete sum read as an exact zero is indistinguishable from a genuine zero-token review, which
121
+ * is the defect this field exists to avoid. */
122
+ readonly reviewerTokens?: number | null;
123
+ /** review-cost-ledger FR-2/A2: the same four components broken out, present only alongside a
124
+ * successful {@link reviewerCostUsd}. fix-round-1 #5: `partial:true` when the sidecar's `total`
125
+ * above is `null` for the reason described there — the four components themselves are still the
126
+ * real (possibly zero-substituted) values, only the aggregate is withheld. */
127
+ readonly reviewerTokensBreakdown?: {
128
+ readonly input: number;
129
+ readonly output: number;
130
+ readonly cacheCreation: number;
131
+ readonly cacheRead: number;
132
+ readonly partial?: true;
133
+ };
134
+ /** review-cost-ledger FR-2/A2/A3: `'qe-bridge-stdout'` when {@link reviewerCostUsd} is a real,
135
+ * parsed price; `'unavailable'` when a reviewer is known but no usable price could be found (the
136
+ * Codex limit, an unreadable/absent/unparseable stdout sidecar, or — fix-round-1 #2 — a sidecar
137
+ * price that exists but is NOT tied to this row's reviewer). Present ONLY in the same branch as
138
+ * {@link reviewMinutes}/{@link reviewSource} above (`reviewSource` is `'qe-bridge'` or
139
+ * `'flag+qe-bridge'`) — WITH two named exceptions, neither of which ever yields an `'ok'` price:
140
+ * (a) a Codex reviewer supplied via `--reviewer` with no matching signoff still gets `'unavailable'`
141
+ * + a reason naming the limit (ADR-001 п.3/FR-4); (b) an explicit `--reviewer` alongside a sidecar
142
+ * that carries a REAL cost record but is NOT tied to it (empty `gradedBy`, i.e. never fills
143
+ * `reviewer` and never agrees with it either) also gets `'unavailable'` — fix-round-1 #2 (Codex r1
144
+ * CRITICAL #2): an untied sidecar price, even `status:'ok'`, is NEVER attributed to an explicit
145
+ * reviewer it was not written for; a DISAGREEING sidecar (real `gradedBy`, does not match the flag)
146
+ * is refused outright by `closeRound` before any row is built at all (fix-round-1 #1/BLOCKER). */
147
+ readonly reviewerCostSource?: 'qe-bridge-stdout' | 'unavailable';
148
+ /** review-cost-ledger FR-2/A3: present ONLY alongside `reviewerCostSource:'unavailable'` — the
149
+ * status text explaining WHY (never a bare unexplained gap, same discipline as `shipShaReason`). */
150
+ readonly reviewerCostReason?: string;
151
+ }
152
+
153
+ /**
154
+ * measurement-integrity FR-7: the qe-bridge cross-model-review signoff for THIS round — read by the
155
+ * CLI from `features/<slug>/.fa-state/qe-bridge/signoff-*.json`, selected by matching `slug` AND
156
+ * falling inside THIS round's own `[startedAt, closedAt]` interval (fix-round-1/F9, Codex r1 HIGH
157
+ * #9 — the CLI-side lookup, `findQeBridgeSignoffForRound`, is what enforces that; multiple qualifying
158
+ * signoffs there are an AMBIGUITY refusal, never "the latest wins"), passed in here as DATA.
159
+ * `closeRound` never opens a file.
160
+ */
161
+ export interface RoundReviewSidecar {
162
+ /** Who graded it — family + model, e.g. `codex:gpt-5.6-sol`. */
163
+ readonly gradedBy: string;
164
+ readonly elapsedMs: number;
165
+ /** The sidecar's OWN verdict, when it carries one — checked against `--grade` for a conflict. */
166
+ readonly grade?: string | null;
167
+ /** measurement-integrity fix-round-1/F9: the slug this signoff was written FOR — carried through so
168
+ * a reader of the eventual ledger row can independently confirm it was not another slug's file. */
169
+ readonly slug?: string;
170
+ /** measurement-integrity fix-round-1/F9: the qe-bridge review's OWN internal run id (from the
171
+ * signoff filename, `signoff-<runId>.json`) — a DIFFERENT id space from `RoundState.run` (which
172
+ * names a feature-adr pipeline run); carried for traceability/audit, never used as a join key. */
173
+ readonly runId?: string | null;
174
+ /** measurement-integrity fix-round-1/F9: when this signoff was emitted — the timestamp
175
+ * `findQeBridgeSignoffForRound` uses to confirm it falls inside THIS round's own interval. */
176
+ readonly emittedAt?: string;
177
+ /** review-cost-ledger FR-2/T3 (ADR-001 п.1): the reviewer's own price, read by the CALLER (the cli
178
+ * — `readQeBridgeCostSidecar`, T3) from the signoff's `rawStdoutFile` and parsed by
179
+ * `parseQeBridgeStdoutCost` (T1). `round.ts` never opens a file itself, same discipline as
180
+ * `gradedBy`/`elapsedMs` above. Absent when the caller never attempted a cost lookup at all
181
+ * (distinct from `{status:'absent'}`, which means a lookup WAS attempted and found nothing). */
182
+ readonly cost?: QeBridgeCost;
54
183
  }
55
184
 
56
185
  type RoundRefusal = { readonly ok: false; readonly exit: 1 | 2; readonly reason: string };
@@ -59,6 +188,25 @@ function nonEmpty(value: unknown): value is string {
59
188
  return typeof value === 'string' && value.trim() !== '';
60
189
  }
61
190
 
191
+ /** review-cost-ledger fix-round-1 #1/#2 (ADR-001 п.2 amended): does an explicit `--reviewer` AGREE
192
+ * with the qe-bridge sidecar's own `gradedBy` (already normalized by the caller to `family:model`,
193
+ * e.g. `claude:sonnet`)? Case-insensitive. An exact `family:model` match agrees; a FAMILY-ONLY flag
194
+ * (no `:` in it at all, e.g. `claude`) agrees with ANY `family:*` gradedBy of that same family —
195
+ * the lead naming only the family (not a specific model) is still confirming the sidecar's identity.
196
+ * Anything else (a different family, or a full `family:model` that does not match exactly) does NOT
197
+ * agree — `closeRound` refuses that case outright rather than guessing which one is right. */
198
+ function reviewerAgreesWithSidecar(reviewerFlag: string, gradedBy: string): boolean {
199
+ const flag = reviewerFlag.trim().toLowerCase();
200
+ const graded = gradedBy.trim().toLowerCase();
201
+ if (flag === '' || graded === '') return false;
202
+ if (flag === graded) return true;
203
+ if (!flag.includes(':')) {
204
+ const gradedFamily = graded.split(':')[0] ?? '';
205
+ return flag === gradedFamily;
206
+ }
207
+ return false;
208
+ }
209
+
62
210
  function validSlug(value: string): boolean {
63
211
  return /^[a-z0-9][a-z0-9._-]*$/i.test(value);
64
212
  }
@@ -67,6 +215,12 @@ function validCount(value: number | undefined): boolean {
67
215
  return value === undefined || (Number.isInteger(value) && value >= 0);
68
216
  }
69
217
 
218
+ /** experiment-instrument A1: a `--task` value is a non-empty string, at most 120 characters, with no
219
+ * control characters (C0 or DEL) — long/garbled input refuses rather than being silently minted. */
220
+ function validTaskId(value: string): boolean {
221
+ return value.trim() !== '' && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
222
+ }
223
+
70
224
  export function openRound(input: {
71
225
  readonly slug: string;
72
226
  readonly round: number;
@@ -81,10 +235,25 @@ export function openRound(input: {
81
235
  readonly force: boolean;
82
236
  readonly existingOwnerAlive: boolean | null;
83
237
  readonly isRunAlive: (runId: string) => boolean | null;
238
+ /** experiment-envelope FR-3(в)/AC-5: when present, validated BEFORE anything else — an invalid
239
+ * envelope refuses the open (exit 2) with the validator's own reason, same as any other malformed
240
+ * input to this command. Absent stays absent (round open without --envelope is unaffected). */
241
+ readonly envelope?: unknown;
242
+ /** experiment-instrument FR-1 (ADR-001): explicit task identity for this round. Absent ⇒
243
+ * `slug@startedAt` (minted below); present but invalid (empty, >120 chars, control characters) ⇒
244
+ * refused (A1) — never silently substituted with the default. */
245
+ readonly task?: string | undefined;
84
246
  }): { readonly ok: true; readonly state: RoundState; readonly archiveExisting: boolean } | RoundRefusal {
85
247
  if (!validSlug(input.slug) || !Number.isInteger(input.round) || input.round < 1 || !nonEmpty(input.topic)) {
86
248
  return { ok: false, exit: 2, reason: 'нужны безопасный --slug, положительный --round и непустой --topic' };
87
249
  }
250
+ if (input.envelope !== undefined) {
251
+ const v = validateExperimentEnvelope(input.envelope);
252
+ if (!v.ok) return { ok: false, exit: 2, reason: `--envelope invalid — ${v.reason}` };
253
+ }
254
+ if (input.task !== undefined && !validTaskId(input.task)) {
255
+ return { ok: false, exit: 2, reason: '--task должен быть непустой строкой ≤120 символов без управляющих символов' };
256
+ }
88
257
  const runOwner = input.ownerKind === 'run';
89
258
  if (!Number.isFinite(Date.parse(input.startedAt)) || !Number.isInteger(input.ownerPid)
90
259
  || (runOwner ? input.ownerPid !== 0 || !nonEmpty(input.ownerRun) : input.ownerPid < 1)
@@ -101,6 +270,8 @@ export function openRound(input: {
101
270
  ...(runOwner ? { ownerRun: input.ownerRun!.trim() } : {}),
102
271
  ...(nonEmpty(input.run) ? { run: input.run.trim() } : {}),
103
272
  recalled: [...input.recalled],
273
+ ...(input.envelope !== undefined ? { envelope: input.envelope } : {}),
274
+ taskId: input.task !== undefined ? input.task.trim() : `${input.slug}@${input.startedAt}`,
104
275
  };
105
276
  let existingOwnerAlive = input.existingOwnerAlive;
106
277
  if (input.existing?.ownerKind === 'run' && input.force) {
@@ -138,13 +309,40 @@ export function closeRound(input: {
138
309
  readonly closedAt: string;
139
310
  readonly knownLessonIds: readonly string[];
140
311
  readonly stateId?: string | undefined;
312
+ /** measurement-integrity FR-7: `A`, `A-`, `B+`, … — mandatory for `shipped|refuted`, a
313
+ * warned-and-dropped no-op for `blocked|abandoned`. */
314
+ readonly grade?: string | undefined;
315
+ /** measurement-integrity FR-7: the qe-bridge signoff for this slug, read by the CALLER. */
316
+ readonly reviewSidecar?: RoundReviewSidecar | undefined;
317
+ /** experiment-instrument FR-3/A2/NFR-3: the git sha the CLI resolved (`git rev-parse HEAD`) — the
318
+ * core never shells out, so this always arrives as data, `null` when it could not be resolved.
319
+ * Only consulted for a FINISHED outcome (`shipped|refuted`); ignored otherwise. */
320
+ readonly shipSha?: string | null | undefined;
321
+ /** experiment-instrument A2: why `shipSha` is `null` (e.g. "not a git repository", "git failed: …")
322
+ * — required to explain a null sha on a finished outcome, ignored when `shipSha` is non-null. */
323
+ readonly shipShaReason?: string | undefined;
324
+ /** experiment-instrument r1-5: whether `git status --porcelain` in `projectRoot` was non-empty at
325
+ * close time (cli-resolved, same discipline as `shipSha` — the core never shells out). Ignored for
326
+ * an unfinished outcome, same as `shipSha`. */
327
+ readonly shipTreeDirty?: boolean | undefined;
328
+ /** experiment-instrument r1-5: why `shipTreeDirty` could not be determined — required only when the
329
+ * probe itself failed (`shipTreeDirty` absent), ignored otherwise. */
330
+ readonly shipTreeDirtyReason?: string | undefined;
141
331
  }, io: {
142
332
  readonly writeLedger: (row: RoundLedgerRow) => unknown;
143
333
  readonly readLedgerTail: () => string;
144
- }): { readonly ok: true; readonly row: RoundLedgerRow; readonly marker: string } | RoundRefusal {
334
+ }): { readonly ok: true; readonly row: RoundLedgerRow; readonly marker: string; readonly warnings: readonly string[] } | RoundRefusal {
145
335
  if (!(ROUND_OUTCOMES as readonly string[]).includes(input.outcome)) {
146
336
  return { ok: false, exit: 2, reason: '--outcome: shipped | refuted | blocked | abandoned' };
147
337
  }
338
+ // fix-round-1/F6: `openRound` validated the envelope once, at open time, then trusted it verbatim
339
+ // out of the state FILE from then on. A state file is mutable disk state between open and close —
340
+ // corrupted or hand-edited in that window, it would ride an invalid/tampered envelope straight
341
+ // into the ledger row. Re-validating here, right before the row is built, closes that window.
342
+ if (input.state.envelope !== undefined) {
343
+ const v = validateExperimentEnvelope(input.state.envelope);
344
+ if (!v.ok) return { ok: false, exit: 2, reason: `envelope in round state invalid — ${v.reason}` };
345
+ }
148
346
  if (!validCount(input.tokens) || !validCount(input.agents)) {
149
347
  return { ok: false, exit: 2, reason: '--tokens и --agents должны быть целыми числами не меньше нуля' };
150
348
  }
@@ -160,6 +358,158 @@ export function closeRound(input: {
160
358
  const missing = lessons.find((id) => !/^teach:[a-z0-9]+$/i.test(id) || !known.has(id));
161
359
  if (missing !== undefined) return { ok: false, exit: 1, reason: `урок не найден: ${missing}` };
162
360
 
361
+ // measurement-integrity FR-7 (ADR-001 D5): grade is mandatory for a FINISHED review
362
+ // (shipped|refuted), a warned-and-dropped no-op for one that never finished (blocked|abandoned —
363
+ // "Отвергнуто: --grade всегда обязателен — заблокированный круг оценки не имеет"). Placed AFTER
364
+ // the outcome/envelope/tokens/lesson checks above (unchanged ordering, unchanged refusal reasons
365
+ // for those) and BEFORE the duration/row-build below.
366
+ const outcome = input.outcome as RoundOutcome;
367
+ const finishedOutcome = outcome === 'shipped' || outcome === 'refuted';
368
+ const gradeFlagRaw = nonEmpty(input.grade) ? input.grade.trim() : null;
369
+ if (gradeFlagRaw !== null && !/^[A-F][+-]?$/.test(gradeFlagRaw)) {
370
+ return { ok: false, exit: 2, reason: `--grade "${gradeFlagRaw}" не распознан — ожидается вид A|A-|B+|C…F` };
371
+ }
372
+ const warnings: string[] = [];
373
+ // measurement-integrity fix-round-1/F10 (Codex r1 MEDIUM #10): for an UNFINISHED outcome, --grade
374
+ // is dropped-with-warning HERE, BEFORE it is ever compared against the sidecar. The OLD ordering
375
+ // ran the conflict check first — so `--outcome blocked --grade B` against a STALE sidecar grade
376
+ // `A` refused outright instead of the promised warning-and-drop, because a value that was about to
377
+ // be discarded still had to survive a conflict check on its way to being discarded. `gradeFlag` is
378
+ // `null` for an unfinished outcome from this point on, exactly as if `--grade` had never been
379
+ // passed — the conflict check below therefore never sees it.
380
+ const gradeFlag = finishedOutcome ? gradeFlagRaw : null;
381
+ if (!finishedOutcome && gradeFlagRaw !== null) {
382
+ warnings.push(`--grade "${gradeFlagRaw}" проигнорирован: outcome=${outcome} не является завершённым ревью, оценка не пишется`);
383
+ }
384
+ const sidecarGrade = input.reviewSidecar !== undefined && nonEmpty(input.reviewSidecar.grade ?? undefined)
385
+ ? (input.reviewSidecar.grade as string).trim()
386
+ : null;
387
+ if (gradeFlag !== null && sidecarGrade !== null && gradeFlag !== sidecarGrade) {
388
+ return {
389
+ ok: false,
390
+ exit: 2,
391
+ reason: `--grade "${gradeFlag}" конфликтует с оценкой сайдкара qe-bridge "${sidecarGrade}" для slug ${input.state.slug}`,
392
+ };
393
+ }
394
+ if (finishedOutcome && gradeFlag === null) {
395
+ return { ok: false, exit: 2, reason: `grade required for a finished review: outcome=${outcome} требует --grade <A|A-|B+|…>` };
396
+ }
397
+ const gradeForRow = gradeFlag;
398
+
399
+ // reviewer/reviewMinutes/reviewSource: reviewer is TIED to the sidecar (ADR-001 п.2, amended by
400
+ // fix-round-1 #1/#2 after the Codex r1 BLOCKER/CRITICAL pair) either (a) it is FILLED from the
401
+ // sidecar (no explicit --reviewer), or (b) it is an explicit --reviewer that AGREES with the
402
+ // sidecar's own gradedBy. Case (b) exists because the pipeline's normal path ALWAYS passes
403
+ // --reviewer (feature-adr.js) — under the old "flag always wins, sidecar never trusted for cost"
404
+ // rule, the price this feature exists to record would NEVER be written on that path (BLOCKER #1).
405
+ // A DISAGREEING explicit --reviewer is refused outright, below — never silently ignored, and never
406
+ // silently misattributed (CRITICAL #2).
407
+ let reviewer = nonEmpty(input.reviewer) ? input.reviewer.trim() : null;
408
+ let reviewMinutes: number | null = null;
409
+ let reviewSource: 'qe-bridge' | 'flag+qe-bridge' | null = null;
410
+ const sidecarGradedBy = input.reviewSidecar !== undefined && nonEmpty(input.reviewSidecar.gradedBy)
411
+ ? input.reviewSidecar.gradedBy.trim()
412
+ : null;
413
+ if (reviewer === null && sidecarGradedBy !== null) {
414
+ reviewer = sidecarGradedBy;
415
+ const elapsedMs = input.reviewSidecar!.elapsedMs;
416
+ reviewMinutes = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? Math.round((elapsedMs / 60_000) * 10) / 10 : null;
417
+ reviewSource = 'qe-bridge';
418
+ } else if (reviewer !== null && sidecarGradedBy !== null) {
419
+ if (reviewerAgreesWithSidecar(reviewer, sidecarGradedBy)) {
420
+ const elapsedMs = input.reviewSidecar!.elapsedMs;
421
+ reviewMinutes = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? Math.round((elapsedMs / 60_000) * 10) / 10 : null;
422
+ reviewSource = 'flag+qe-bridge';
423
+ } else {
424
+ // fix-round-1 #1/#2 (ADR-001 п.2 amended): the same discipline a --grade disagreeing with the
425
+ // sidecar's own grade already follows (FR-7) — an explicit reviewer naming someone ELSE than
426
+ // the sidecar's own reviewer must never silently receive (or silently omit) that reviewer's
427
+ // price; it refuses, naming both values, before any row is built.
428
+ return {
429
+ ok: false,
430
+ exit: 2,
431
+ reason: `--reviewer "${reviewer}" conflicts with the qe-bridge sidecar's reviewer "${sidecarGradedBy}" for slug ${input.state.slug}`,
432
+ };
433
+ }
434
+ }
435
+
436
+ // review-cost-ledger FR-3/A4 (ADR-001 п.4): a FINISHED review (shipped|refuted) with NEITHER an
437
+ // explicit --reviewer NOR one filled from the sidecar is unmeasurable by definition — the audit
438
+ // this feature exists to close (ADR-001 Q5). blocked|abandoned carry no such requirement, same as
439
+ // they carry no grade requirement above: a review that never finished has no reviewer to name yet.
440
+ if (finishedOutcome && reviewer === null) {
441
+ return {
442
+ ok: false,
443
+ exit: 2,
444
+ reason: `finished review requires a reviewer: outcome=${outcome} needs --reviewer <model> (no --reviewer flag and no qe-bridge signoff to fill it from)`,
445
+ };
446
+ }
447
+
448
+ // review-cost-ledger FR-2/A2/A3 (ADR-001 п.1-3, amended by fix-round-1 #2/#3/#5): the reviewer's
449
+ // price is written ONLY when the row's reviewer is TIED to the sidecar — reviewSource is
450
+ // 'qe-bridge' or 'flag+qe-bridge' (the two cases computed above). fix-round-1 #2 (Codex r1
451
+ // CRITICAL #2): the old "or it carries no gradedBy at all" hack is REMOVED — an untrusted sidecar
452
+ // (empty gradedBy, e.g. T3's Codex-no-signoff synthesis) can still explain an ABSENT/UNPARSEABLE
453
+ // price (that is a structural fact about the instrument, not an attribution), but it can NEVER
454
+ // yield an 'ok' price for a reviewer it was not written for — an adversarial ok-cost sidecar with
455
+ // an empty gradedBy is exactly the CRITICAL #2 failing input, and it must never buy a price here.
456
+ let reviewerCostFields: Pick<RoundLedgerRow, 'reviewerCostUsd' | 'reviewerTokens' | 'reviewerTokensBreakdown' | 'reviewerCostSource' | 'reviewerCostReason'> | null = null;
457
+ const sidecarCost = input.reviewSidecar?.cost;
458
+ const reviewerTiedToSidecar = reviewSource === 'qe-bridge' || reviewSource === 'flag+qe-bridge';
459
+ if (reviewerTiedToSidecar) {
460
+ // fix-round-1 #3 (Codex r1 HIGH #3): a sidecar that filled/confirmed the reviewer but carries NO
461
+ // cost record at all (cost undefined) used to leave every price field silently absent — the same
462
+ // "never a bare unexplained gap" discipline as `shipShaReason` now applies here too.
463
+ if (sidecarCost === undefined) {
464
+ reviewerCostFields = { reviewerCostUsd: null, reviewerCostSource: 'unavailable', reviewerCostReason: 'sidecar carried no cost record' };
465
+ } else if (sidecarCost.status === 'ok') {
466
+ // fix-round-1 #5 (Codex r1 HIGH #5): tokensPartial on the sidecar means the sum is NOT a real
467
+ // count (at least one component was missing) — reviewerTokens goes null rather than a silently
468
+ // zeroed/short sum, while the still-valid price and the partial breakdown are kept.
469
+ const partial = sidecarCost.tokens.tokensPartial === true;
470
+ reviewerCostFields = {
471
+ reviewerCostUsd: sidecarCost.costUsd,
472
+ reviewerTokens: partial ? null : sidecarCost.tokens.total,
473
+ reviewerTokensBreakdown: {
474
+ input: sidecarCost.tokens.input,
475
+ output: sidecarCost.tokens.output,
476
+ cacheCreation: sidecarCost.tokens.cacheCreation,
477
+ cacheRead: sidecarCost.tokens.cacheRead,
478
+ ...(partial ? { partial: true as const } : {}),
479
+ },
480
+ reviewerCostSource: 'qe-bridge-stdout',
481
+ };
482
+ } else {
483
+ const reason = nonEmpty(sidecarCost.reason)
484
+ ? sidecarCost.reason.trim()
485
+ : 'qe-bridge stdout sidecar carried no parseable cost line';
486
+ reviewerCostFields = { reviewerCostUsd: null, reviewerCostSource: 'unavailable', reviewerCostReason: reason };
487
+ }
488
+ } else if (sidecarCost !== undefined) {
489
+ // The reviewer is NOT tied to this sidecar (an untrusted/untied sidecar — e.g. T3's
490
+ // Codex-no-signoff synthesis, empty gradedBy). It may still explain a NAMED limit
491
+ // (status !== 'ok') — that is honest instrument metadata, not an attribution. fix-round-1 #2: an
492
+ // 'ok' status here is NEVER trusted for a price, whatever the number — the sidecar was not
493
+ // written for this reviewer, full stop.
494
+ if (sidecarCost.status !== 'ok') {
495
+ // Lead delta after Codex r2 (#2 partial): an untied sidecar's OWN reason (the cli's Codex
496
+ // "tokens not visible" synthesis) was copied onto ANY explicit reviewer's row — a Claude
497
+ // reviewer got a Codex-specific limit as its explanation. The sidecar's reason travels only
498
+ // to a reviewer of the codex family; every other untied reviewer gets the neutral fact.
499
+ const reviewerIsCodex = /^codex\b/i.test(reviewer ?? '');
500
+ const reason = reviewerIsCodex && nonEmpty(sidecarCost.reason)
501
+ ? sidecarCost.reason.trim()
502
+ : 'explicit --reviewer is not tied to a qe-bridge sidecar (no signoff names this reviewer)';
503
+ reviewerCostFields = { reviewerCostUsd: null, reviewerCostSource: 'unavailable', reviewerCostReason: reason };
504
+ } else {
505
+ reviewerCostFields = {
506
+ reviewerCostUsd: null,
507
+ reviewerCostSource: 'unavailable',
508
+ reviewerCostReason: 'qe-bridge sidecar carried a price but is not tied to this row\'s reviewer',
509
+ };
510
+ }
511
+ }
512
+
163
513
  const startedMs = Date.parse(input.state.startedAt);
164
514
  const closedMs = Date.parse(input.closedAt);
165
515
  if (!Number.isFinite(startedMs) || !Number.isFinite(closedMs) || closedMs < startedMs) {
@@ -168,18 +518,49 @@ export function closeRound(input: {
168
518
  const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
169
519
  const marker = `round-${input.state.slug}-${input.state.round}-${compactTs}`;
170
520
  const note = input.note?.trim() ?? '';
521
+
522
+ // experiment-instrument FR-1/A4/A5: the state ALWAYS carries a taskId for a round opened after this
523
+ // feature; a state written before it lacks the key on disk, so the same default `openRound` mints
524
+ // is derived here and the derivation is named (`taskIdSource`) — never silently blended with a
525
+ // fresh-mint taskId, which is what `taskIdSource` absent means.
526
+ const taskIdFromState = input.state.taskId;
527
+ const taskId = nonEmpty(taskIdFromState) ? taskIdFromState.trim() : `${input.state.slug}@${input.state.startedAt}`;
528
+ const taskIdSource: 'derived-legacy' | null = nonEmpty(taskIdFromState) ? null : 'derived-legacy';
529
+
530
+ // experiment-instrument FR-3/A2: only a FINISHED outcome carries a ship anchor — `blocked|abandoned`
531
+ // never shipped anything, so a sha there would misleadingly imply a release. A null sha REQUIRES its
532
+ // reason to be recorded next to it (never a bare, unexplained null on a finished row) — r1-6 (Codex
533
+ // r1 MEDIUM #6): a caller that supplies no reason at all no longer leaves the row silently
534
+ // unexplained; a stable default fills the gap instead of an absent key.
535
+ const finishedForShip = outcome === 'shipped' || outcome === 'refuted';
536
+ const shipSha = finishedForShip ? (input.shipSha ?? null) : null;
537
+ const shipShaReason = finishedForShip && shipSha === null
538
+ ? (nonEmpty(input.shipShaReason) ? input.shipShaReason.trim() : 'not provided')
539
+ : null;
540
+ // experiment-instrument r1-5 (Codex r1 HIGH #5, ADR-001 amended): `shipTreeDirty` is present only
541
+ // when the caller (the cli) actually resolved it; `shipTreeDirtyReason` fills the gap when it could
542
+ // not be. Neither is ever guessed here — the core never shells out to compute either.
543
+ const shipTreeDirtyValue: boolean | undefined = finishedForShip && typeof input.shipTreeDirty === 'boolean'
544
+ ? input.shipTreeDirty
545
+ : undefined;
546
+ // r2-2 (Codex r2 MEDIUM N2, lead delta): a finished row with NEITHER field used to omit both —
547
+ // the same bare-gap shape r1-6 closed for the sha. The rule is symmetric: boolean, or a reason.
548
+ const shipTreeDirtyReason = finishedForShip && shipTreeDirtyValue === undefined
549
+ ? (nonEmpty(input.shipTreeDirtyReason) ? input.shipTreeDirtyReason.trim() : 'not provided')
550
+ : undefined;
551
+
171
552
  const row: RoundLedgerRow = {
172
553
  slug: input.state.slug,
173
554
  stage: 'round',
174
555
  tier: null,
175
556
  coder: nonEmpty(input.coder) ? input.coder.trim() : null,
176
- reviewer: nonEmpty(input.reviewer) ? input.reviewer.trim() : null,
557
+ reviewer,
177
558
  lead: null,
178
559
  minutes: input.noCost === true ? null : Math.floor((closedMs - startedMs) / 60_000),
179
560
  agents: input.noCost === true ? null : input.agents ?? null,
180
561
  tokens: input.noCost === true ? null : input.tokens ?? null,
181
- grade: null,
182
- outcome: input.outcome as RoundOutcome,
562
+ grade: gradeForRow,
563
+ outcome,
183
564
  reason: nonEmpty(input.reason) ? input.reason.trim() : null,
184
565
  round: input.state.round,
185
566
  lessons,
@@ -188,6 +569,16 @@ export function closeRound(input: {
188
569
  date: null,
189
570
  ...(input.noCost === true ? { costIn: 'stages' as const } : {}),
190
571
  ...(nonEmpty(input.stateId) ? { stateId: input.stateId } : {}),
572
+ ...(input.state.envelope !== undefined ? { envelope: input.state.envelope } : {}),
573
+ ...(reviewSource !== null ? { reviewSource } : {}),
574
+ ...(reviewMinutes !== null ? { reviewMinutes } : {}),
575
+ taskId,
576
+ ...(taskIdSource !== null ? { taskIdSource } : {}),
577
+ ...(finishedForShip ? { shipSha, shippedAt: input.closedAt } : {}),
578
+ ...(shipShaReason !== null ? { shipShaReason } : {}),
579
+ ...(shipTreeDirtyValue !== undefined ? { shipTreeDirty: shipTreeDirtyValue } : {}),
580
+ ...(shipTreeDirtyReason !== undefined ? { shipTreeDirtyReason } : {}),
581
+ ...(reviewerCostFields !== null ? reviewerCostFields : {}),
191
582
  };
192
583
 
193
584
  try {
@@ -200,7 +591,91 @@ export function closeRound(input: {
200
591
  if (!tail.includes(marker)) {
201
592
  return { ok: false, exit: 1, reason: 'строка не найдена — круг НЕ закрыт' };
202
593
  }
203
- return { ok: true, row, marker };
594
+ return { ok: true, row, marker, warnings };
595
+ }
596
+
597
+ /**
598
+ * measurement-integrity fix-round-1/F8 (Codex r1 CRITICAL #8): validate an ALREADY-WRITTEN ledger
599
+ * row against the CURRENT schema's `shipped|refuted require a grade` rule (ADR-001 D5 / FR-7).
600
+ *
601
+ * This exists for exactly one caller: `dz round close`'s idempotent-retry path. When the predicted
602
+ * marker (or `stateId`) is already found in the ledger tail, the CLI used to skip `closeRound`
603
+ * ENTIRELY and delete the round's state file — so an OLD row written before FR-7 shipped (`shipped`
604
+ * with `grade: null`, the exact defect this feature exists to close) could be "recognised as already
605
+ * closed" and the state removed without ever being checked against the rule that is supposed to be
606
+ * mandatory. This function is that missing check, run on the ALREADY-FOUND row before the CLI is
607
+ * allowed to treat the retry as a success.
608
+ *
609
+ * Deliberately NARROW: it re-checks only the ONE FR-7 invariant (a schema rule with a proving test),
610
+ * not every field `closeRound` validates on the FIRST write (grade format, envelope shape, …) — those
611
+ * were already enforced when the row was ORIGINALLY written; re-validating them here would either
612
+ * duplicate that logic or silently drift from it. Pure, never throws.
613
+ */
614
+ export function validateClosedRoundLedgerRow(row: unknown): { readonly ok: true } | { readonly ok: false; readonly reason: string } {
615
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
616
+ return { ok: false, reason: 'найденная строка леджера не JSON-объект — не удаётся проверить оценку' };
617
+ }
618
+ const r = row as Record<string, unknown>;
619
+ const outcome = r['outcome'];
620
+ if (typeof outcome !== 'string' || !(ROUND_OUTCOMES as readonly string[]).includes(outcome)) {
621
+ return { ok: false, reason: `найденная строка леджера несёт неизвестный outcome ${JSON.stringify(outcome)}` };
622
+ }
623
+ const finishedOutcome = outcome === 'shipped' || outcome === 'refuted';
624
+ if (!finishedOutcome) return { ok: true }; // blocked|abandoned carry no grade requirement (FR-7)
625
+ const grade = r['grade'];
626
+ if (typeof grade !== 'string' || !/^[A-F][+-]?$/.test(grade)) {
627
+ return {
628
+ ok: false,
629
+ reason: `найденная строка леджера — outcome=${outcome} без валидной оценки (grade=${JSON.stringify(grade)}); ` +
630
+ 'закрытие отказано (measurement-integrity ADR-001 D5 / FR-7 требует непустую оценку для завершённого ревью)',
631
+ };
632
+ }
633
+ return { ok: true };
634
+ }
635
+
636
+ /**
637
+ * experiment-instrument FR-1/FR-3 (ADR-001 D-A): the single source every OTHER writer (an auto ledger
638
+ * row, a qe-bridge signoff, a control-review row) consults to find this slug's task identity —
639
+ * never guessed, never minted here. `states` is whatever the CALLER already read as "currently open
640
+ * round state files for this slug" (the cli walks `.dz/rounds/<slug>-*.json`); this function does not
641
+ * touch a filesystem and does not assume the caller pre-filtered by slug, so it filters again itself.
642
+ *
643
+ * `unreadableStateCount` (r1-2, Codex r1 HIGH #2) is how many FILENAMES the caller found matching this
644
+ * slug's pattern that it could NOT parse into a `RoundState` — a corrupt or half-written state file is
645
+ * never silent absence; it means the true open-round count for this slug is UNKNOWN, not zero.
646
+ *
647
+ * - Any unreadable candidate exists AND no readable one does ⇒ `{taskId: null, source: 'unavailable'}`
648
+ * — a round MAY be open here, but its state cannot be read (r1-2).
649
+ * - Any unreadable candidate exists ALONGSIDE at least one readable one ⇒ `{taskId: null, source:
650
+ * 'ambiguous'}` — the readable one might not be the only genuinely open round (r1-2).
651
+ * - Zero candidates at all (readable or not) ⇒ `{taskId: null, source: 'no-open-round'}` — no open
652
+ * round, nothing to fill from (A4).
653
+ * - Two or more readable matches, no unreadable ones ⇒ `{taskId: null, source: 'ambiguous'}` — which
654
+ * one is authoritative is not decidable here; a NULL is the honest answer, never "the latest wins" (A5).
655
+ * - Exactly one readable match, no unreadable ones ⇒ its `taskId` when the state carries one
656
+ * (`source: 'open-round'`), or the SAME `slug@startedAt` default `openRound`/`closeRound` would
657
+ * derive for a legacy state missing the field on disk — labelled `source: 'derived-legacy'` (r1-1,
658
+ * Codex r1 CRITICAL #1: this used to report `'open-round'` for an invented value, indistinguishable
659
+ * from a value the round itself actually minted).
660
+ */
661
+ export function readOpenRoundTaskId(
662
+ states: readonly RoundState[],
663
+ slug: string,
664
+ unreadableStateCount = 0,
665
+ ): {
666
+ readonly taskId: string | null;
667
+ readonly source: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
668
+ } {
669
+ const matches = states.filter((s) => s.slug === slug);
670
+ const unreadable = Number.isFinite(unreadableStateCount) && unreadableStateCount > 0 ? Math.floor(unreadableStateCount) : 0;
671
+ if (unreadable > 0 && matches.length === 0) return { taskId: null, source: 'unavailable' };
672
+ if (unreadable > 0) return { taskId: null, source: 'ambiguous' };
673
+ if (matches.length === 0) return { taskId: null, source: 'no-open-round' };
674
+ if (matches.length > 1) return { taskId: null, source: 'ambiguous' };
675
+ const state = matches[0]!;
676
+ const taskId = nonEmpty(state.taskId) ? state.taskId.trim() : `${state.slug}@${state.startedAt}`;
677
+ const source = nonEmpty(state.taskId) ? 'open-round' : 'derived-legacy';
678
+ return { taskId, source };
204
679
  }
205
680
 
206
681
  export function listRounds(states: readonly RoundState[], input: {