@dzhechkov/harness-core 0.8.37 → 0.8.39

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 (164) hide show
  1. package/.dz-manifest.json +253 -133
  2. package/README.md +209 -0
  3. package/dist/agentdb-index.d.ts +3 -3
  4. package/dist/agentdb-index.js +5 -5
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +4 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/backlog.d.ts +2 -1
  10. package/dist/backlog.d.ts.map +1 -1
  11. package/dist/backlog.js +3 -2
  12. package/dist/backlog.js.map +1 -1
  13. package/dist/brain.d.ts.map +1 -1
  14. package/dist/brain.js +9 -2
  15. package/dist/brain.js.map +1 -1
  16. package/dist/bto-optimize.d.ts.map +1 -1
  17. package/dist/bto-optimize.js +9 -12
  18. package/dist/bto-optimize.js.map +1 -1
  19. package/dist/claim-check.d.ts.map +1 -1
  20. package/dist/claim-check.js +50 -14
  21. package/dist/claim-check.js.map +1 -1
  22. package/dist/cross-family-control.d.ts +35 -0
  23. package/dist/cross-family-control.d.ts.map +1 -1
  24. package/dist/cross-family-control.js +49 -3
  25. package/dist/cross-family-control.js.map +1 -1
  26. package/dist/experiment-assign.d.ts +110 -0
  27. package/dist/experiment-assign.d.ts.map +1 -0
  28. package/dist/experiment-assign.js +229 -0
  29. package/dist/experiment-assign.js.map +1 -0
  30. package/dist/feature-adr-checkpoints.d.ts +7 -2
  31. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  32. package/dist/feature-adr-checkpoints.js +20 -5
  33. package/dist/feature-adr-checkpoints.js.map +1 -1
  34. package/dist/feature-adr-envelope.d.ts +12 -0
  35. package/dist/feature-adr-envelope.d.ts.map +1 -1
  36. package/dist/feature-adr-envelope.js +12 -1
  37. package/dist/feature-adr-envelope.js.map +1 -1
  38. package/dist/feature-adr-routing.d.ts +4 -0
  39. package/dist/feature-adr-routing.d.ts.map +1 -1
  40. package/dist/feature-adr-routing.js +15 -1
  41. package/dist/feature-adr-routing.js.map +1 -1
  42. package/dist/index.d.ts +12 -5
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +11 -4
  45. package/dist/index.js.map +1 -1
  46. package/dist/ledger-cost-fill.d.ts +58 -0
  47. package/dist/ledger-cost-fill.d.ts.map +1 -0
  48. package/dist/ledger-cost-fill.js +78 -0
  49. package/dist/ledger-cost-fill.js.map +1 -0
  50. package/dist/loop-blobs.generated.js +2 -2
  51. package/dist/loop-blobs.generated.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +29 -0
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +20 -0
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/name-check.d.ts +20 -1
  57. package/dist/name-check.d.ts.map +1 -1
  58. package/dist/name-check.js +42 -1
  59. package/dist/name-check.js.map +1 -1
  60. package/dist/no-stubs.d.ts +10 -0
  61. package/dist/no-stubs.d.ts.map +1 -1
  62. package/dist/no-stubs.js +13 -9
  63. package/dist/no-stubs.js.map +1 -1
  64. package/dist/patterns.d.ts +24 -0
  65. package/dist/patterns.d.ts.map +1 -1
  66. package/dist/patterns.js +49 -9
  67. package/dist/patterns.js.map +1 -1
  68. package/dist/publish-source-scope.d.ts +32 -0
  69. package/dist/publish-source-scope.d.ts.map +1 -0
  70. package/dist/publish-source-scope.js +53 -0
  71. package/dist/publish-source-scope.js.map +1 -0
  72. package/dist/publish.d.ts +5 -0
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +32 -21
  75. package/dist/publish.js.map +1 -1
  76. package/dist/qe-bridge.d.ts +12 -1
  77. package/dist/qe-bridge.d.ts.map +1 -1
  78. package/dist/qe-bridge.js +21 -11
  79. package/dist/qe-bridge.js.map +1 -1
  80. package/dist/rake-analyzer.d.ts +10 -3
  81. package/dist/rake-analyzer.d.ts.map +1 -1
  82. package/dist/rake-analyzer.js +84 -22
  83. package/dist/rake-analyzer.js.map +1 -1
  84. package/dist/recap.d.ts.map +1 -1
  85. package/dist/recap.js +8 -4
  86. package/dist/recap.js.map +1 -1
  87. package/dist/release.d.ts +2 -1
  88. package/dist/release.d.ts.map +1 -1
  89. package/dist/release.js +18 -4
  90. package/dist/release.js.map +1 -1
  91. package/dist/reqe.d.ts.map +1 -1
  92. package/dist/reqe.js +11 -1
  93. package/dist/reqe.js.map +1 -1
  94. package/dist/review-cost.d.ts +51 -0
  95. package/dist/review-cost.d.ts.map +1 -0
  96. package/dist/review-cost.js +110 -0
  97. package/dist/review-cost.js.map +1 -0
  98. package/dist/round.d.ts +136 -3
  99. package/dist/round.d.ts.map +1 -1
  100. package/dist/round.js +215 -6
  101. package/dist/round.js.map +1 -1
  102. package/dist/run-records.d.ts +51 -0
  103. package/dist/run-records.d.ts.map +1 -1
  104. package/dist/run-records.js +148 -2
  105. package/dist/run-records.js.map +1 -1
  106. package/dist/score.d.ts.map +1 -1
  107. package/dist/score.js +8 -1
  108. package/dist/score.js.map +1 -1
  109. package/dist/sign.d.ts +23 -0
  110. package/dist/sign.d.ts.map +1 -1
  111. package/dist/sign.js +52 -0
  112. package/dist/sign.js.map +1 -1
  113. package/dist/skills-verify.d.ts +8 -5
  114. package/dist/skills-verify.d.ts.map +1 -1
  115. package/dist/skills-verify.js +59 -7
  116. package/dist/skills-verify.js.map +1 -1
  117. package/dist/store-guard-prune.d.ts +22 -0
  118. package/dist/store-guard-prune.d.ts.map +1 -0
  119. package/dist/store-guard-prune.js +54 -0
  120. package/dist/store-guard-prune.js.map +1 -0
  121. package/dist/sweep-failure-classify.d.ts +45 -0
  122. package/dist/sweep-failure-classify.d.ts.map +1 -0
  123. package/dist/sweep-failure-classify.js +90 -0
  124. package/dist/sweep-failure-classify.js.map +1 -0
  125. package/dist/workflow-run-dispatch.d.ts +14 -2
  126. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  127. package/dist/workflow-run-dispatch.js +14 -2
  128. package/dist/workflow-run-dispatch.js.map +1 -1
  129. package/package.json +1 -1
  130. package/sbom.json +432 -132
  131. package/src/agentdb-index.ts +5 -5
  132. package/src/amendment-trace.ts +4 -1
  133. package/src/backlog.ts +3 -2
  134. package/src/brain.ts +8 -2
  135. package/src/bto-optimize.ts +10 -8
  136. package/src/claim-check.ts +48 -12
  137. package/src/cross-family-control.ts +81 -3
  138. package/src/experiment-assign.ts +276 -0
  139. package/src/feature-adr-checkpoints.ts +20 -5
  140. package/src/feature-adr-envelope.ts +24 -1
  141. package/src/feature-adr-routing.ts +15 -1
  142. package/src/index.ts +23 -4
  143. package/src/loop-blobs.generated.ts +2 -2
  144. package/src/mutation-gate.ts +59 -0
  145. package/src/name-check.ts +43 -1
  146. package/src/no-stubs.ts +14 -5
  147. package/src/patterns.ts +49 -8
  148. package/src/publish-source-scope.ts +53 -0
  149. package/src/publish.ts +38 -16
  150. package/src/qe-bridge.ts +30 -11
  151. package/src/rake-analyzer.ts +75 -20
  152. package/src/rake-signatures.json +98 -0
  153. package/src/recap.ts +8 -4
  154. package/src/release.ts +18 -4
  155. package/src/reqe.ts +11 -1
  156. package/src/review-cost.ts +139 -0
  157. package/src/round.ts +327 -11
  158. package/src/run-records.ts +175 -1
  159. package/src/score.ts +8 -1
  160. package/src/sign.ts +53 -0
  161. package/src/skills-verify.ts +69 -9
  162. package/src/store-guard-prune.ts +81 -0
  163. package/src/sweep-failure-classify.ts +88 -0
  164. package/src/workflow-run-dispatch.ts +14 -2
@@ -0,0 +1,139 @@
1
+ /**
2
+ * review-cost-ledger T1/FR-1/A1 (ADR-001 п.1): a pure parser for the qe-bridge reviewer's own cost
3
+ * receipt — the last non-empty line of the Claude CLI's raw stdout, which (for every observed
4
+ * signoff) is a JSON object carrying `total_cost_usd`, `usage.*`, `duration_ms`, `num_turns`.
5
+ *
6
+ * Deliberately narrow and honest: absence of a price is never silently read as a price of zero.
7
+ * `status:'absent'` (no non-empty line at all) and `status:'unparseable'` (a line that IS present
8
+ * but does not parse into a usable cost) are each their own outcome — never collapsed into `ok`, and
9
+ * never guessed. This module owns no filesystem access (NFR-2): the caller (the cli) reads the
10
+ * stdout sidecar file and hands its TEXT in here.
11
+ */
12
+
13
+ /** The four cost-bearing token components, plus their sum. `tokensPartial` is present (`true`) only
14
+ * when at least one component was missing/unusable in the source JSON and was substituted with 0 —
15
+ * never silently blended with a genuine zero. */
16
+ export interface QeBridgeCostTokens {
17
+ readonly input: number;
18
+ readonly output: number;
19
+ readonly cacheCreation: number;
20
+ readonly cacheRead: number;
21
+ readonly total: number;
22
+ readonly tokensPartial?: true;
23
+ }
24
+
25
+ /**
26
+ * `ok` — a usable price was found. `absent` — no non-empty line to read at all (an empty/whitespace
27
+ * stdout, or a caller who never had one to offer); `reason` is OPTIONAL here because the parser
28
+ * itself never has anything more specific to say about a literal absence, but a CALLER (the cli's
29
+ * `readQeBridgeCostSidecar`, T3) may attach its own known reason to a synthesized `absent` (e.g. "no
30
+ * qe-bridge signoff for this round") without inventing a THIRD status for that case. `unparseable` —
31
+ * a non-empty last line existed but did not yield a valid cost (not JSON, not an object, no numeric
32
+ * `total_cost_usd`, or a negative/NaN one) — `reason` is mandatory here, naming what failed.
33
+ */
34
+ export type QeBridgeCost =
35
+ | {
36
+ readonly status: 'ok';
37
+ readonly costUsd: number;
38
+ readonly tokens: QeBridgeCostTokens;
39
+ readonly durationMs: number | null;
40
+ readonly numTurns: number | null;
41
+ }
42
+ | { readonly status: 'absent'; readonly reason?: string }
43
+ | { readonly status: 'unparseable'; readonly reason: string };
44
+
45
+ function finiteNonNegative(value: unknown): value is number {
46
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
47
+ }
48
+
49
+ function finiteNumberOrNull(value: unknown): number | null {
50
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
51
+ }
52
+
53
+ /** fix-round-1 #6 (Codex r1 HIGH #6): a token COMPONENT is a count, never a measurement — it is only
54
+ * ever trustworthy as a nonnegative SAFE integer. `1e308` is `typeof 'number'` and finite, but adding
55
+ * four of those silently produces `Infinity` (which then serializes to `null` on the ledger row while
56
+ * the in-memory type still claims `number`); a fractional or >2^53 value is likewise not a real token
57
+ * count. Distinct from "component ABSENT" (undefined — stays the existing partial-with-zero path). */
58
+ function safeNonNegativeInteger(value: unknown): value is number {
59
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
60
+ }
61
+
62
+ /**
63
+ * `text` is the full raw stdout of a qe-bridge reviewer invocation (Claude CLI, `--output-format
64
+ * json`-shaped result line). The COST line is always the LAST non-empty line — Claude CLI streams
65
+ * intermediate events first and finishes with one `type:"result"` object carrying the run's totals.
66
+ */
67
+ export function parseQeBridgeStdoutCost(text: string): QeBridgeCost {
68
+ const lines = text.split('\n');
69
+ let lastLine = '';
70
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
71
+ const candidate = lines[i]!.trim();
72
+ if (candidate !== '') { lastLine = candidate; break; }
73
+ }
74
+ if (lastLine === '') return { status: 'absent' };
75
+
76
+ let parsed: unknown;
77
+ try {
78
+ parsed = JSON.parse(lastLine);
79
+ } catch (error) {
80
+ return { status: 'unparseable', reason: `last non-empty line is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
81
+ }
82
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
83
+ return { status: 'unparseable', reason: 'last non-empty line did not parse to a JSON object' };
84
+ }
85
+ const rec = parsed as Record<string, unknown>;
86
+ const totalCostUsd = rec['total_cost_usd'];
87
+ if (!finiteNonNegative(totalCostUsd)) {
88
+ return {
89
+ status: 'unparseable',
90
+ reason: typeof totalCostUsd === 'number'
91
+ ? `total_cost_usd is not a finite non-negative number: ${totalCostUsd}`
92
+ : `total_cost_usd field is missing or not a number (got ${JSON.stringify(totalCostUsd)})`,
93
+ };
94
+ }
95
+
96
+ const usageRaw = rec['usage'];
97
+ const usage = typeof usageRaw === 'object' && usageRaw !== null && !Array.isArray(usageRaw)
98
+ ? (usageRaw as Record<string, unknown>)
99
+ : {};
100
+ let tokensPartial = false;
101
+ // fix-round-1 #6: a MISSING component (undefined) is the pre-existing partial-with-zero case
102
+ // (never touched by this fix); a PRESENT-but-invalid component (adversarial: 1e308, a fraction,
103
+ // 2^53+1, negative, NaN) is a different failure — the whole result turns `unparseable` rather than
104
+ // silently zeroing a value that was actually there. `invalidComponent` names which key failed.
105
+ let invalidComponent: string | null = null;
106
+ const component = (key: string): number => {
107
+ const value = usage[key];
108
+ if (value === undefined) { tokensPartial = true; return 0; }
109
+ if (safeNonNegativeInteger(value)) return value;
110
+ invalidComponent = key;
111
+ return 0;
112
+ };
113
+ const input = component('input_tokens');
114
+ const output = component('output_tokens');
115
+ const cacheCreation = component('cache_creation_input_tokens');
116
+ const cacheRead = component('cache_read_input_tokens');
117
+ if (invalidComponent !== null) {
118
+ return { status: 'unparseable', reason: `usage.${invalidComponent} is not a nonnegative safe integer: ${JSON.stringify(usage[invalidComponent])}` };
119
+ }
120
+ const total = input + output + cacheCreation + cacheRead;
121
+ if (!Number.isSafeInteger(total)) {
122
+ return { status: 'unparseable', reason: `token total ${total} exceeds Number.MAX_SAFE_INTEGER — not a trustworthy count` };
123
+ }
124
+
125
+ return {
126
+ status: 'ok',
127
+ costUsd: totalCostUsd,
128
+ tokens: {
129
+ input,
130
+ output,
131
+ cacheCreation,
132
+ cacheRead,
133
+ total,
134
+ ...(tokensPartial ? { tokensPartial: true as const } : {}),
135
+ },
136
+ durationMs: finiteNumberOrNull(rec['duration_ms']),
137
+ numTurns: finiteNumberOrNull(rec['num_turns']),
138
+ };
139
+ }
package/src/round.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { validateExperimentEnvelope } from './feature-adr-envelope.js';
8
+ import type { QeBridgeCost } from './review-cost.js';
8
9
 
9
10
  const ROUND_OUTCOMES = ['shipped', 'refuted', 'blocked', 'abandoned'] as const;
10
11
  type RoundOutcome = typeof ROUND_OUTCOMES[number];
@@ -24,6 +25,11 @@ export interface RoundState {
24
25
  * unchanged through `round open --envelope` into `closeRound`'s ledger row. Opaque here (never
25
26
  * interpreted by round.ts) — validated once, at `openRound`, and trusted from then on. */
26
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;
27
33
  }
28
34
 
29
35
  export interface RoundExecState {
@@ -63,11 +69,85 @@ export interface RoundLedgerRow {
63
69
  * closes — lets a retried `close` detect its own earlier row regardless of the clock. */
64
70
  readonly stateId?: string;
65
71
  /** measurement-integrity FR-7: present ONLY when `reviewer` was filled from the qe-bridge sidecar
66
- * (no explicit `--reviewer`) — `elapsedMs / 60000`, rounded to 1 decimal. */
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). */
67
75
  readonly reviewMinutes?: number;
68
76
  /** measurement-integrity FR-7: present ONLY alongside `reviewMinutes` — names where `reviewer` and
69
- * `reviewMinutes` came from, so a reader never confuses a sidecar-sourced figure for a flag. */
70
- readonly reviewSource?: 'qe-bridge';
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;
71
151
  }
72
152
 
73
153
  /**
@@ -94,6 +174,12 @@ export interface RoundReviewSidecar {
94
174
  /** measurement-integrity fix-round-1/F9: when this signoff was emitted — the timestamp
95
175
  * `findQeBridgeSignoffForRound` uses to confirm it falls inside THIS round's own interval. */
96
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;
97
183
  }
98
184
 
99
185
  type RoundRefusal = { readonly ok: false; readonly exit: 1 | 2; readonly reason: string };
@@ -102,6 +188,25 @@ function nonEmpty(value: unknown): value is string {
102
188
  return typeof value === 'string' && value.trim() !== '';
103
189
  }
104
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
+
105
210
  function validSlug(value: string): boolean {
106
211
  return /^[a-z0-9][a-z0-9._-]*$/i.test(value);
107
212
  }
@@ -110,6 +215,12 @@ function validCount(value: number | undefined): boolean {
110
215
  return value === undefined || (Number.isInteger(value) && value >= 0);
111
216
  }
112
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
+
113
224
  export function openRound(input: {
114
225
  readonly slug: string;
115
226
  readonly round: number;
@@ -128,6 +239,10 @@ export function openRound(input: {
128
239
  * envelope refuses the open (exit 2) with the validator's own reason, same as any other malformed
129
240
  * input to this command. Absent stays absent (round open without --envelope is unaffected). */
130
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;
131
246
  }): { readonly ok: true; readonly state: RoundState; readonly archiveExisting: boolean } | RoundRefusal {
132
247
  if (!validSlug(input.slug) || !Number.isInteger(input.round) || input.round < 1 || !nonEmpty(input.topic)) {
133
248
  return { ok: false, exit: 2, reason: 'нужны безопасный --slug, положительный --round и непустой --topic' };
@@ -136,6 +251,9 @@ export function openRound(input: {
136
251
  const v = validateExperimentEnvelope(input.envelope);
137
252
  if (!v.ok) return { ok: false, exit: 2, reason: `--envelope invalid — ${v.reason}` };
138
253
  }
254
+ if (input.task !== undefined && !validTaskId(input.task)) {
255
+ return { ok: false, exit: 2, reason: '--task должен быть непустой строкой ≤120 символов без управляющих символов' };
256
+ }
139
257
  const runOwner = input.ownerKind === 'run';
140
258
  if (!Number.isFinite(Date.parse(input.startedAt)) || !Number.isInteger(input.ownerPid)
141
259
  || (runOwner ? input.ownerPid !== 0 || !nonEmpty(input.ownerRun) : input.ownerPid < 1)
@@ -153,6 +271,7 @@ export function openRound(input: {
153
271
  ...(nonEmpty(input.run) ? { run: input.run.trim() } : {}),
154
272
  recalled: [...input.recalled],
155
273
  ...(input.envelope !== undefined ? { envelope: input.envelope } : {}),
274
+ taskId: input.task !== undefined ? input.task.trim() : `${input.slug}@${input.startedAt}`,
156
275
  };
157
276
  let existingOwnerAlive = input.existingOwnerAlive;
158
277
  if (input.existing?.ownerKind === 'run' && input.force) {
@@ -195,6 +314,20 @@ export function closeRound(input: {
195
314
  readonly grade?: string | undefined;
196
315
  /** measurement-integrity FR-7: the qe-bridge signoff for this slug, read by the CALLER. */
197
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;
198
331
  }, io: {
199
332
  readonly writeLedger: (row: RoundLedgerRow) => unknown;
200
333
  readonly readLedgerTail: () => string;
@@ -263,18 +396,118 @@ export function closeRound(input: {
263
396
  }
264
397
  const gradeForRow = gradeFlag;
265
398
 
266
- // reviewer/reviewMinutes/reviewSource: an explicit --reviewer always wins; the sidecar fills the
267
- // gap only, and only its OWN two derived fields travel with it (a flag-supplied reviewer never
268
- // carries a sidecar-sourced `reviewMinutes`/`reviewSource` — that would misattribute where the
269
- // minutes figure came from).
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).
270
407
  let reviewer = nonEmpty(input.reviewer) ? input.reviewer.trim() : null;
271
408
  let reviewMinutes: number | null = null;
272
- let reviewSource: 'qe-bridge' | null = null;
273
- if (reviewer === null && input.reviewSidecar !== undefined && nonEmpty(input.reviewSidecar.gradedBy)) {
274
- reviewer = input.reviewSidecar.gradedBy.trim();
275
- const elapsedMs = input.reviewSidecar.elapsedMs;
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;
276
416
  reviewMinutes = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? Math.round((elapsedMs / 60_000) * 10) / 10 : null;
277
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
+ }
278
511
  }
279
512
 
280
513
  const startedMs = Date.parse(input.state.startedAt);
@@ -285,6 +518,37 @@ export function closeRound(input: {
285
518
  const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
286
519
  const marker = `round-${input.state.slug}-${input.state.round}-${compactTs}`;
287
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
+
288
552
  const row: RoundLedgerRow = {
289
553
  slug: input.state.slug,
290
554
  stage: 'round',
@@ -308,6 +572,13 @@ export function closeRound(input: {
308
572
  ...(input.state.envelope !== undefined ? { envelope: input.state.envelope } : {}),
309
573
  ...(reviewSource !== null ? { reviewSource } : {}),
310
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 : {}),
311
582
  };
312
583
 
313
584
  try {
@@ -362,6 +633,51 @@ export function validateClosedRoundLedgerRow(row: unknown): { readonly ok: true
362
633
  return { ok: true };
363
634
  }
364
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 };
679
+ }
680
+
365
681
  export function listRounds(states: readonly RoundState[], input: {
366
682
  readonly now: number;
367
683
  readonly olderThanMinutes: number;