@dzhechkov/harness-core 0.8.11 → 0.8.21

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 (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Сторож смены лида: отказать в завершении хода, пока санкционированная работа не кончилась.
3
+ *
4
+ * ЗАЧЕМ ЭТО СУЩЕСТВУЕТ — два измеренных происшествия, второе после починки первого.
5
+ *
6
+ * 2026-08-29: простой ~4,5 часа. Лид «мониторил» задачи, чьи родители давно умерли, и считал
7
+ * очередь непустой. Лечением стало ПРАВИЛО в `.claude/rules/lead-idle-discipline.md`.
8
+ *
9
+ * 2026-09-03: простой **6 часов 24 минуты**. Лид отработал 19:16–23:15, написал утренний отчёт,
10
+ * ЗАВЕРШИЛ ХОД и молчал до 05:38. Очередь была не просто непустой — 352 открытые записи, и
11
+ * незакрытая работа была перечислена самим лидом абзацем выше в том же отчёте. Правило,
12
+ * написанное после первого случая, читалось каждый прогон и не сработало.
13
+ *
14
+ * Вывод, который и породил этот файл: **текст второго слоя лестницы стоимости обнаружения не
15
+ * ловит собственный молчаливый отказ.** Правило нельзя «не выполнить с ошибкой» — его можно
16
+ * только не вспомнить, и снаружи это неотличимо от выполнения. Поэтому решение переносится на
17
+ * слой 1: детерминированная функция, у которой есть красный тест.
18
+ *
19
+ * СТРУКТУРНАЯ ТРУДНОСТЬ, названная честно: завершение хода — это ОТСУТСТВИЕ дальнейших вызовов,
20
+ * а не событие. Перехватить «модель перестала работать» нельзя. Зато харнесс даёт шов `Stop`,
21
+ * который срабатывает на попытке закончить ход и умеет её ЗАБЛОКИРОВАТЬ. Этот файл — решающая
22
+ * половина такого гейта; вторая половина, тонкий адаптер, живёт в хуке.
23
+ *
24
+ * АТАКА, СЛОМАВШАЯ ПЕРВУЮ РЕДАКЦИЮ (Codex gpt-5.6-sol, xhigh, 2026-09-03, вердикт «НЕ СРАБОТАЕТ»).
25
+ * Первая редакция разрешала ПОВТОРНУЮ остановку автоматически — как защиту от зацикливания.
26
+ * Приём обхода: получить отказ, ответить «принято, продолжу», не сделать ни одного рабочего
27
+ * действия, остановиться снова — и второй раз пропускали. Итог был бы тот же простой плюс одна
28
+ * запись в журнале. Поэтому здесь повторная остановка разрешается НЕ ПО ФАКТУ ПОВТОРНОСТИ, а
29
+ * только по машинному свидетельству прогресса (см. `hasProgress`).
30
+ */
31
+
32
+ /** Машинное свидетельство того, что между двумя попытками остановиться что-то произошло. */
33
+ export interface ProgressEvidence {
34
+ /** `git rev-parse HEAD` на момент прошлой блокировки и сейчас. */
35
+ readonly headBefore?: string | undefined;
36
+ readonly headNow?: string | undefined;
37
+ /** sha256 от `git status --porcelain` — ловит правку, ещё не ставшую коммитом. */
38
+ readonly treeHashBefore?: string | undefined;
39
+ readonly treeHashNow?: string | undefined;
40
+ /** Идентификаторы живых фоновых задач тогда и сейчас. */
41
+ readonly liveTasksBefore?: readonly string[] | undefined;
42
+ readonly liveTasksNow?: readonly string[] | undefined;
43
+ /**
44
+ * Пункты очереди, закрытые между попытками, вместе с доказательством закрытия.
45
+ * `evidenceExists` считает ВЫЗЫВАЮЩИЙ, проверив путь или коммит на существование: закрытие
46
+ * без существующего доказательства прогрессом не считается, иначе «закрыть» станет строкой.
47
+ */
48
+ readonly closedItems?: readonly { readonly id: string; readonly evidenceExists: boolean }[] | undefined;
49
+ }
50
+
51
+ /** Состояние смены: что владелец санкционировал и в каком режиме идёт работа. */
52
+ export interface LeadShift {
53
+ /** `autonomous` — владелец отпустил лида работать без присмотра; иначе сторож спит. */
54
+ readonly mode: 'autonomous' | 'interactive';
55
+ /** Открытых пунктов в санкционированной очереди. */
56
+ readonly openItems: number;
57
+ /** Живая работа: фоновые задачи, чьи родители ЖИВЫ. Пустой список — не повод спать. */
58
+ readonly liveWork: readonly string[];
59
+ /** Минут с последнего сообщения ЧЕЛОВЕКА. Свежий разговор — законная причина остановиться. */
60
+ readonly minutesSinceHuman: number;
61
+ /** Это повторная попытка остановиться после нашей же блокировки. */
62
+ readonly stopHookActive: boolean;
63
+ /** Сколько раз подряд мы уже блокировали в этой смене. */
64
+ readonly consecutiveBlocks: number;
65
+ /** Квитанция завершения из закрытого списка: владелец сказал стоп, очередь пуста, работа блокирована. */
66
+ readonly completionReceipt?: 'owner-stop' | 'queue-empty' | 'blocked-on-owner' | undefined;
67
+ }
68
+
69
+ /** Что сторож решил и почему. `reason` пишется в журнал инцидентов и показывается лиду. */
70
+ export interface StopDecision {
71
+ readonly action: 'allow' | 'block' | 'escalate';
72
+ readonly reason: string;
73
+ /** Машинный код причины — для журнала и для тестов; текст `reason` меняется, код нет. */
74
+ readonly code:
75
+ | 'not-autonomous' | 'receipt' | 'live-work' | 'fresh-human' | 'queue-empty'
76
+ | 'idle-with-queue' | 'no-progress' | 'budget-exhausted';
77
+ }
78
+
79
+ /** Потолок блокировок подряд. Ниже харнессового потолка в 8, чтобы решение осталось нашим. */
80
+ export const BLOCK_BUDGET = 3;
81
+
82
+ /** Свежий разговор с человеком: остановиться сразу после его реплики — законно. */
83
+ export const FRESH_HUMAN_MINUTES = 20;
84
+
85
+ /**
86
+ * Было ли между двумя попытками остановиться хоть одно машинно заметное движение.
87
+ *
88
+ * Ответ «принято, продолжу работу» не меняет ни коммита, ни дерева, ни списка задач, ни очереди —
89
+ * и потому прогрессом не является. В этом весь смысл функции: она отличает СЛОВА от ДЕЙСТВИЙ.
90
+ */
91
+ export function hasProgress(e: ProgressEvidence | undefined): boolean {
92
+ if (!e) return false;
93
+ if (e.headBefore && e.headNow && e.headBefore !== e.headNow) return true;
94
+ if (e.treeHashBefore && e.treeHashNow && e.treeHashBefore !== e.treeHashNow) return true;
95
+ const before = new Set(e.liveTasksBefore ?? []);
96
+ if ((e.liveTasksNow ?? []).some((id) => !before.has(id))) return true;
97
+ // Закрытие пункта считается прогрессом ТОЛЬКО с существующим доказательством: иначе достаточно
98
+ // объявить пункт закрытым, чтобы получить право замолчать.
99
+ if ((e.closedItems ?? []).some((c) => c.evidenceExists)) return true;
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Решить, можно ли закончить ход.
105
+ *
106
+ * Порядок проверок load-bearing: сначала всё, что делает остановку ЗАКОННОЙ, и только потом
107
+ * запрет. Сторож, который сначала запрещает, будет отключён после первого же ложного срабатывания
108
+ * на правильной остановке — а отключённый сторож не ловит ничего.
109
+ */
110
+ export function decideStop(shift: LeadShift, progress?: ProgressEvidence): StopDecision {
111
+ if (shift.mode !== 'autonomous') {
112
+ return { action: 'allow', code: 'not-autonomous', reason: 'смена не в автономном режиме — остановка не наше дело' };
113
+ }
114
+ if (shift.completionReceipt) {
115
+ return { action: 'allow', code: 'receipt', reason: `квитанция завершения: ${shift.completionReceipt}` };
116
+ }
117
+ if (shift.liveWork.length > 0) {
118
+ return { action: 'allow', code: 'live-work', reason: `живая работа: ${shift.liveWork.join(', ')}` };
119
+ }
120
+ if (shift.minutesSinceHuman < FRESH_HUMAN_MINUTES) {
121
+ return { action: 'allow', code: 'fresh-human', reason: `человек писал ${shift.minutesSinceHuman} мин назад — разговор идёт` };
122
+ }
123
+ if (shift.openItems === 0) {
124
+ return { action: 'allow', code: 'queue-empty', reason: 'очередь пуста' };
125
+ }
126
+ // Дальше — остановка при непустой очереди, без живой работы и без квитанции. Ровно этот случай
127
+ // произошёл 2026-09-03 и стоил 6 часов 24 минут.
128
+ if (shift.consecutiveBlocks >= BLOCK_BUDGET) {
129
+ return {
130
+ action: 'escalate',
131
+ code: 'budget-exhausted',
132
+ reason: `${shift.consecutiveBlocks} блокировки подряд без прогресса — будим владельца и разрешаем остановку`,
133
+ };
134
+ }
135
+ if (shift.stopHookActive && hasProgress(progress)) {
136
+ return { action: 'allow', code: 'no-progress', reason: 'после прошлой блокировки есть машинный прогресс' };
137
+ }
138
+ return {
139
+ action: 'block',
140
+ code: shift.stopHookActive ? 'no-progress' : 'idle-with-queue',
141
+ reason: shift.stopHookActive
142
+ ? `повторная остановка без единого машинного признака прогресса; открытых пунктов ${shift.openItems}`
143
+ : `в очереди ${shift.openItems} открытых пунктов, живой работы нет — ход не заканчивается`,
144
+ };
145
+ }
@@ -59,7 +59,7 @@ export interface LedgerBackfillRow {
59
59
  readonly key: LedgerFillKey | null;
60
60
  readonly filled: readonly string[];
61
61
  /** Why nothing was filled. `null` when something was. */
62
- readonly skipped: 'no-join-key' | 'no-host-record' | 'ambiguous-slug' | 'shared-run-claim' | 'already-complete' | 'malformed-line' | 'host-record-empty' | null;
62
+ readonly skipped: 'no-join-key' | 'no-host-record' | 'ambiguous-slug' | 'shared-run-claim' | 'already-complete' | 'malformed-line' | 'host-record-empty' | 'non-run-row' | null;
63
63
  }
64
64
 
65
65
  export interface LedgerBackfillPlan {
@@ -75,6 +75,17 @@ const FILLABLE = ['tokens', 'minutes', 'agents'] as const;
75
75
 
76
76
  const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
77
77
 
78
+ /**
79
+ * fa-phase-statusline QE fix (P2, cross-family review of 656d6903): the ledger now carries
80
+ * per-PHASE telemetry rows (`kind:"phase"`, schema `feature-adr-phase/1`) alongside its run rows.
81
+ * The header's contract: the one-line-one-run invariant applies ONLY to rows WITHOUT a `kind`
82
+ * field, so any row that carries one is NOT a run row and must be invisible to the backfill —
83
+ * both to claimant counting (several phase rows under a slug were making a real cost row
84
+ * `shared-run-claim`, unfillable forever) and to filling (exactly one phase row under a slug could
85
+ * receive the whole run's token total — a fabrication by attribution).
86
+ */
87
+ export const isNonRunRow = (r: Record<string, unknown>): boolean => typeof r['kind'] === 'string' && r['kind'] !== '';
88
+
78
89
  /**
79
90
  * Plan the fill. PURE: takes the ledger's raw lines and a runId→facts lookup, returns the new lines.
80
91
  * Nothing is read or written here — the caller owns the file and the atomic replace.
@@ -100,6 +111,7 @@ export function planLedgerBackfill(input: {
100
111
  const parsed: unknown = JSON.parse(raw);
101
112
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
102
113
  const r = parsed as Record<string, unknown>;
114
+ if (isNonRunRow(r)) continue; // phase rows are telemetry, never claimants
103
115
  if (FILLABLE.every((f) => isNum(r[f]))) continue;
104
116
  const rid = typeof r['runId'] === 'string' && r['runId'] !== '' ? r['runId'] : null;
105
117
  const slg = typeof r['slug'] === 'string' && r['slug'] !== '' ? r['slug'] : null;
@@ -129,6 +141,13 @@ export function planLedgerBackfill(input: {
129
141
 
130
142
  const runId = typeof row['runId'] === 'string' && row['runId'] !== '' ? row['runId'] : null;
131
143
  const slug = typeof row['slug'] === 'string' && row['slug'] !== '' ? row['slug'] : null;
144
+ if (isNonRunRow(row)) {
145
+ // A `kind`-carrying row (per-phase telemetry) has no run cost to fill — pass through
146
+ // unchanged and SAY so, never let it receive a run's numbers.
147
+ outLines.push(raw);
148
+ rows.push({ index, runId, slug, key: null, filled: [], skipped: 'non-run-row' });
149
+ return;
150
+ }
132
151
  const missing = FILLABLE.filter((f) => !isNum(row[f]));
133
152
  if (missing.length === 0) {
134
153
  outLines.push(raw);
@@ -25,7 +25,7 @@ export interface LoopBlob {
25
25
  code: string;
26
26
  }
27
27
 
28
- export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","trace","loop-semantics","ha-consult-router"] as const;
28
+ export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","stage-line","trace","loop-semantics","ha-consult-router"] as const;
29
29
 
30
30
  /** Workflow files the regen-diff gate covers TODAY (AM-5 honest scope): exactly the files
31
31
  * carrying BEGIN BLOB markers. Stage B (whole-file regeneration of feature-adr.js) is a
@@ -61,11 +61,11 @@ export const BLOBS: Record<string, LoopBlob> = {
61
61
  "model-resolver": {
62
62
  name: "model-resolver",
63
63
  version: "1.0.0",
64
- contentHash: "fcf8c1dfc4b5364adb6e532565953ef30d94a45070b2b903411170965a1fb372",
64
+ contentHash: "357a258796e553b2fbe94dfab26cf487a9fd189b4927836f261185215392dfaa",
65
65
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-routing.ts",
66
66
  requires: [],
67
67
  exports: ["specToOpts","resolveStageModel","KNOWN_CODEX","mergeOpts","stageLabel","modelLabel"],
68
- code: "const STAGE_EFFORT = { override: {\n router: 'medium',\n requirements: 'medium',\n research: 'medium',\n adr: 'high',\n ideation: 'medium',\n ddd: 'high',\n architecture: 'high',\n plan: 'high',\n code: 'medium',\n qe: 'high',\n fleet: 'medium',\n } };\nfunction topCodexId(env) {\n return env.CODEX_MODEL !== 'auto' ? env.CODEX_MODEL : CODEX_TIERS.flagship;\n}\nconst KNOWN_CODEX = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-luna': 1, 'gpt-5.6-terra': 1, 'gpt-5.6-sol': 1 };\nconst CODEX_TIERS = {\n flagship: 'gpt-5.6-sol',\n workhorse: 'gpt-5.6-terra',\n 'high-volume': 'gpt-5.6-luna',\n};\nfunction codexIdForTier(tier, env) {\n return env.CODEX_MODEL !== 'auto' ? env.CODEX_MODEL : CODEX_TIERS[tier];\n}\nconst BUDGET_PRESETS = {\n normal: { claude: 'normal', codex: 'normal' },\n eco: { claude: 'eco', codex: 'eco' },\n hybrid: { claude: 'eco', codex: 'normal' },\n};\nfunction resolveBudgetMode(raw) {\n if (raw === undefined)\n return BUDGET_PRESETS.normal;\n if (typeof raw === 'string') {\n const preset = BUDGET_PRESETS[raw];\n if (!preset)\n throw new RangeError('budget: unknown preset \"' + raw + '\" — valid: normal|eco|hybrid');\n return preset;\n }\n if (raw && typeof raw === 'object') {\n const value = raw;\n for (const key of Object.keys(value)) {\n if (key !== 'claude' && key !== 'codex') {\n throw new RangeError('budget: unknown family key \"' + key + '\" — valid: claude|codex');\n }\n }\n for (const key of ['claude', 'codex']) {\n const level = value[key];\n if (level !== undefined && level !== 'normal' && level !== 'eco') {\n throw new RangeError('budget.' + key + ': unknown level \"' + level + '\" — valid: normal|eco');\n }\n }\n return {\n claude: value.claude || 'normal',\n codex: value.codex || 'normal',\n };\n }\n throw new RangeError('budget: expected a preset name or {claude,codex} object, got ' + typeof raw);\n}\nconst CLAUDE_NAMES = { fable: 1, opus: 1, sonnet: 1, haiku: 1 };\nconst VALID_REASONING = { none: 1, minimal: 1, low: 1, medium: 1, high: 1, xhigh: 1, max: 1 };\nconst DEFAULT_MODELS = {\n router: 'fable',\n requirements: 'sonnet',\n research: 'sonnet',\n adr: 'opus',\n ideation: 'sonnet',\n ddd: 'opus',\n architecture: 'opus',\n plan: 'sonnet',\n code: null,\n qe: null,\n fleet: 'sonnet',\n};\nconst ROUTING_TABLES = {\n claude: {\n claude: {\n normal: { router: 'sonnet', requirements: 'sonnet', research: 'sonnet', adr: 'fable', ideation: 'sonnet', ddd: 'fable', architecture: 'fable', plan: 'opus', code: 'sonnet', fleet: 'sonnet' },\n eco: { router: 'sonnet', requirements: 'sonnet', research: 'sonnet', adr: 'opus', ideation: 'sonnet', ddd: 'opus', architecture: 'opus', plan: 'sonnet', code: 'sonnet', fleet: 'sonnet' },\n },\n codex: { normal: {}, eco: {} },\n },\n codex: {\n claude: {\n normal: { router: 'sonnet', qe: 'sonnet', fleet: 'sonnet' },\n eco: { router: 'sonnet', qe: 'sonnet', fleet: 'sonnet' },\n },\n codex: { normal: {}, eco: {} },\n },\n};\nfunction codexCell(tier, effort, env) {\n return 'codex:' + codexIdForTier(tier, env) + ':' + effort;\n}\nfunction budgetTable(primary, mode, env) {\n const claudeHalf = ROUTING_TABLES[primary].claude[mode.claude];\n let codexHalf;\n if (primary === 'claude') {\n const qeSpec = mode.codex === 'normal'\n ? codexCell('flagship', 'high', env)\n : codexCell('workhorse', 'medium', env);\n codexHalf = { ...ROUTING_TABLES.claude.codex[mode.codex], qe: env.codexAvailable === false ? 'opus' : qeSpec };\n }\n else {\n const normal = mode.codex === 'normal';\n const design = codexCell(normal ? 'flagship' : 'workhorse', normal ? 'high' : 'medium', env);\n codexHalf = {\n requirements: design,\n research: design,\n adr: design,\n ideation: design,\n ddd: design,\n architecture: design,\n plan: codexCell(normal ? 'flagship' : 'workhorse', normal ? 'high' : 'low', env),\n code: codexCell(normal ? 'flagship' : 'workhorse', 'medium', env),\n };\n }\n return { ...claudeHalf, ...codexHalf };\n}\nfunction specToOpts(spec, env) {\n const log = env.log || function () { };\n if (!spec)\n return {};\n const parts = String(spec).split(':');\n const head = parts[0] || '';\n if (head === 'codex') {\n let id = parts[1] || env.CODEX_MODEL;\n if (id !== 'auto' && !KNOWN_CODEX[id]) {\n log('models: unknown codex id ' + id + ' — using ' + env.CODEX_MODEL);\n id = env.CODEX_MODEL;\n }\n let reasoning = parts[2] || 'high';\n if (!VALID_REASONING[reasoning]) {\n throw new RangeError('models: invalid reasoning \"' + reasoning + '\" — valid: ' + Object.keys(VALID_REASONING).join('|'));\n }\n return { agentType: 'codex:codex-rescue', codexModel: id, _reasoning: reasoning };\n }\n if (CLAUDE_NAMES[head])\n return { model: head };\n log('models: unknown spec ' + spec + ' — session-inherited');\n return {};\n}\nfunction resolveCoderSpec(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return 'codex:' + env.CODEX_MODEL + ':high';\n return 'opus';\n}\nfunction coderIsCodex(env) {\n const codeSpec = env.MODELS.code;\n if (codeSpec !== undefined && codeSpec !== null)\n return String(codeSpec).split(':')[0] === 'codex';\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return true;\n return env.primary === 'codex';\n}\nfunction resolveQeSpecForCoder(coderCodex, env) {\n if (coderCodex)\n return 'sonnet';\n const CODEX_AVAILABLE = env.codexAvailable !== false;\n if (!CODEX_AVAILABLE)\n return 'opus';\n const budget = resolveBudgetMode(env.budget);\n return budget.codex === 'eco'\n ? 'codex:' + codexIdForTier('workhorse', env) + ':medium'\n : 'codex:' + codexIdForTier('flagship', env) + ':high';\n}\nfunction resolveQeSpec(env) {\n return resolveQeSpecForCoder(coderIsCodex(env), env);\n}\nfunction routingRequested(env) {\n return (Object.keys(env.MODELS).length > 0 ||\n env.primary !== undefined ||\n env.budget !== undefined ||\n env.PLANNER === 'codex' ||\n env.CODER === 'codex' ||\n env.CODER === 'codex-fallback' ||\n env.QE_REVIEWER === 'codex' ||\n env.QE_REVIEWER === 'codex-fallback');\n}\nfunction resolveStageModel(stage, env) {\n if (env.usageOverride) {\n const r = (env.usageReasoning && env.usageReasoning[stage]) || STAGE_EFFORT.override[stage] || 'medium';\n const o = specToOpts('codex:' + topCodexId(env) + ':' + r, env);\n o._usageSwitched = true;\n return o;\n }\n let spec = env.MODELS[stage];\n if (spec === undefined) {\n if (!routingRequested(env))\n return {};\n if (stage === 'code' && (env.CODER === 'codex' || env.CODER === 'codex-fallback')) {\n return specToOpts(resolveCoderSpec(env), env);\n }\n if (stage === 'plan' && env.PLANNER === 'codex') {\n return specToOpts('codex:' + env.CODEX_MODEL + ':high', env);\n }\n if (stage === 'qe') {\n return specToOpts(resolveQeSpec(env), env);\n }\n const resolvedPrimary = env.primary || 'claude';\n const cell = budgetTable(resolvedPrimary, resolveBudgetMode(env.budget), env)[stage];\n spec = cell !== undefined ? cell : DEFAULT_MODELS[stage];\n }\n if (stage === 'code' && (spec === null || spec === undefined))\n return specToOpts(resolveCoderSpec(env), env);\n if (stage === 'qe' && (spec === null || spec === undefined))\n return specToOpts(resolveQeSpec(env), env);\n return specToOpts(spec, env);\n}\nfunction modelLabel(opts) {\n if (opts && opts.agentType === 'codex:codex-rescue') {\n const base = 'codex:' + opts.codexModel + ':' + opts._reasoning;\n return opts._usageSwitched ? base + ' (usage-switched)' : base;\n }\n if (opts && opts.model)\n return opts.model;\n return 'session';\n}\nfunction stageLabel(base, opts) {\n const m = modelLabel(opts);\n return m === 'session' ? base : base + ' · ' + m;\n}\nfunction mergeOpts(base, extra) {\n const out = {};\n for (const k in base)\n out[k] = base[k];\n for (const k in extra)\n out[k] = extra[k];\n return out;\n}",
68
+ code: "const STAGE_EFFORT = { override: {\n router: 'medium',\n requirements: 'medium',\n research: 'medium',\n adr: 'high',\n ideation: 'medium',\n ddd: 'high',\n architecture: 'high',\n plan: 'high',\n code: 'medium',\n qe: 'high',\n fleet: 'medium',\n } };\nfunction topCodexId(env) {\n return env.CODEX_MODEL !== 'auto' ? env.CODEX_MODEL : CODEX_TIERS.flagship;\n}\nconst KNOWN_CODEX = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-luna': 1, 'gpt-5.6-terra': 1, 'gpt-5.6-sol': 1 };\nconst CODEX_TIERS = {\n flagship: 'gpt-5.6-sol',\n workhorse: 'gpt-5.6-terra',\n 'high-volume': 'gpt-5.6-luna',\n};\nfunction codexIdForTier(tier, env) {\n return env.CODEX_MODEL !== 'auto' ? env.CODEX_MODEL : CODEX_TIERS[tier];\n}\nconst BUDGET_PRESETS = {\n normal: { claude: 'normal', codex: 'normal' },\n eco: { claude: 'eco', codex: 'eco' },\n hybrid: { claude: 'eco', codex: 'normal' },\n};\nfunction resolveBudgetMode(raw) {\n if (raw === undefined)\n return BUDGET_PRESETS.normal;\n if (typeof raw === 'string') {\n const preset = BUDGET_PRESETS[raw];\n if (!preset)\n throw new RangeError('budget: unknown preset \"' + raw + '\" — valid: normal|eco|hybrid');\n return preset;\n }\n if (raw && typeof raw === 'object') {\n const value = raw;\n for (const key of Object.keys(value)) {\n if (key !== 'claude' && key !== 'codex') {\n throw new RangeError('budget: unknown family key \"' + key + '\" — valid: claude|codex');\n }\n }\n for (const key of ['claude', 'codex']) {\n const level = value[key];\n if (level !== undefined && level !== 'normal' && level !== 'eco') {\n throw new RangeError('budget.' + key + ': unknown level \"' + level + '\" — valid: normal|eco');\n }\n }\n return {\n claude: value.claude || 'normal',\n codex: value.codex || 'normal',\n };\n }\n throw new RangeError('budget: expected a preset name or {claude,codex} object, got ' + typeof raw);\n}\nconst CLAUDE_NAMES = { fable: 1, opus: 1, sonnet: 1, haiku: 1 };\nconst VALID_REASONING = { none: 1, minimal: 1, low: 1, medium: 1, high: 1, xhigh: 1, max: 1 };\nconst DEFAULT_MODELS = {\n router: 'fable',\n requirements: 'sonnet',\n research: 'sonnet',\n adr: 'opus',\n ideation: 'sonnet',\n ddd: 'opus',\n architecture: 'opus',\n plan: 'sonnet',\n code: null,\n qe: null,\n fleet: 'sonnet',\n};\nconst ROUTING_TABLES = {\n claude: {\n claude: {\n normal: { router: 'sonnet', requirements: 'sonnet', research: 'sonnet', adr: 'fable', ideation: 'sonnet', ddd: 'fable', architecture: 'fable', plan: 'opus', code: 'sonnet', fleet: 'sonnet' },\n eco: { router: 'sonnet', requirements: 'sonnet', research: 'sonnet', adr: 'opus', ideation: 'sonnet', ddd: 'opus', architecture: 'opus', plan: 'sonnet', code: 'sonnet', fleet: 'sonnet' },\n },\n codex: { normal: {}, eco: {} },\n },\n codex: {\n claude: {\n normal: { router: 'sonnet', qe: 'sonnet', fleet: 'sonnet' },\n eco: { router: 'sonnet', qe: 'sonnet', fleet: 'sonnet' },\n },\n codex: { normal: {}, eco: {} },\n },\n};\nfunction codexCell(tier, effort, env) {\n return 'codex:' + codexIdForTier(tier, env) + ':' + effort;\n}\nfunction budgetTable(primary, mode, env) {\n const claudeHalf = ROUTING_TABLES[primary].claude[mode.claude];\n let codexHalf;\n if (primary === 'claude') {\n const qeSpec = mode.codex === 'normal'\n ? codexCell('flagship', 'high', env)\n : codexCell('workhorse', 'medium', env);\n codexHalf = { ...ROUTING_TABLES.claude.codex[mode.codex], qe: env.codexAvailable === false ? 'opus' : qeSpec };\n }\n else {\n const normal = mode.codex === 'normal';\n const design = codexCell(normal ? 'flagship' : 'workhorse', normal ? 'high' : 'medium', env);\n codexHalf = {\n requirements: design,\n research: design,\n adr: design,\n ideation: design,\n ddd: design,\n architecture: design,\n plan: codexCell(normal ? 'flagship' : 'workhorse', normal ? 'high' : 'low', env),\n code: codexCell(normal ? 'flagship' : 'workhorse', 'medium', env),\n };\n }\n return { ...claudeHalf, ...codexHalf };\n}\nfunction specToOpts(spec, env) {\n const log = env.log || function () { };\n if (!spec)\n return {};\n const parts = String(spec).split(':');\n const head = parts[0] || '';\n if (head === 'codex') {\n let id = parts[1] || env.CODEX_MODEL;\n if (id !== 'auto' && !KNOWN_CODEX[id]) {\n log('models: unknown codex id ' + id + ' — using ' + env.CODEX_MODEL);\n id = env.CODEX_MODEL;\n }\n let reasoning = parts[2] || 'high';\n if (!VALID_REASONING[reasoning]) {\n throw new RangeError('models: invalid reasoning \"' + reasoning + '\" — valid: ' + Object.keys(VALID_REASONING).join('|'));\n }\n return { agentType: 'codex:codex-rescue', codexModel: id, _reasoning: reasoning };\n }\n if (CLAUDE_NAMES[head])\n return { model: head };\n log('models: unknown spec ' + spec + ' — session-inherited');\n return {};\n}\nfunction resolveCoderSpec(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return 'codex:' + env.CODEX_MODEL + ':high';\n return 'opus';\n}\nfunction coderIsCodex(env) {\n const codeSpec = env.MODELS.code;\n if (codeSpec !== undefined && codeSpec !== null)\n return String(codeSpec).split(':')[0] === 'codex';\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return true;\n return env.primary === 'codex';\n}\nfunction resolveQeSpecForCoder(coderCodex, env) {\n if (coderCodex)\n return 'sonnet';\n const CODEX_AVAILABLE = env.codexAvailable !== false;\n if (!CODEX_AVAILABLE)\n return 'opus';\n const budget = resolveBudgetMode(env.budget);\n return budget.codex === 'eco'\n ? 'codex:' + codexIdForTier('workhorse', env) + ':medium'\n : 'codex:' + codexIdForTier('flagship', env) + ':high';\n}\nfunction resolveQeSpec(env) {\n return resolveQeSpecForCoder(coderIsCodex(env), env);\n}\nfunction routingRequested(env) {\n return (Object.keys(env.MODELS).length > 0 ||\n env.primary !== undefined ||\n env.budget !== undefined ||\n env.PLANNER === 'codex' ||\n env.CODER === 'codex' ||\n env.CODER === 'codex-fallback' ||\n env.QE_REVIEWER === 'codex' ||\n env.QE_REVIEWER === 'codex-fallback');\n}\nfunction resolveStageModel(stage, env) {\n return resolveStageDecision(stage, env).opts;\n}\nfunction specFamily(spec) {\n return (spec && String(spec).split(':')[0] === 'codex') ? 'codex' : 'claude';\n}\nfunction qeReasonForFamilies(coderCodex, qeSpec) {\n if (specFamily(qeSpec) === (coderCodex ? 'codex' : 'claude'))\n return 'qe-same-family-degraded';\n return 'qe-cross-family';\n}\nfunction effectiveSpec(opts) {\n if (opts && opts.agentType === 'codex:codex-rescue')\n return 'codex:' + opts.codexModel + ':' + opts._reasoning;\n if (opts && opts.model)\n return opts.model;\n return null;\n}\nfunction specDegradation(spec, opts, env) {\n if (!spec)\n return null;\n const parts = String(spec).split(':');\n const head = parts[0] || '';\n if (head === 'codex') {\n const requested = parts[1] || env.CODEX_MODEL;\n if (opts.codexModel !== requested)\n return 'codex-id-substituted';\n return null;\n }\n if (!CLAUDE_NAMES[head])\n return 'spec-unrecognised';\n return null;\n}\nfunction decisionFor(base, spec, opts, env) {\n const deg = specDegradation(spec, opts, env);\n if (deg !== null)\n return { opts: opts, spec: effectiveSpec(opts), reason: deg };\n return { opts: opts, spec: effectiveSpec(opts), reason: base };\n}\nfunction decideFromSpec(base, stage, spec, env) {\n if (stage === 'code' && (spec === null || spec === undefined)) {\n const s = resolveCoderSpec(env);\n return decisionFor(base, s, specToOpts(s, env), env);\n }\n if (stage === 'qe' && (spec === null || spec === undefined)) {\n const s = resolveQeSpec(env);\n return decisionFor(base, s, specToOpts(s, env), env);\n }\n return decisionFor(base, spec, specToOpts(spec, env), env);\n}\nfunction resolveStageDecision(stage, env) {\n if (env.usageOverride) {\n const r = (env.usageReasoning && env.usageReasoning[stage]) || STAGE_EFFORT.override[stage] || 'medium';\n const s = 'codex:' + topCodexId(env) + ':' + r;\n const o = specToOpts(s, env);\n o._usageSwitched = true;\n return decisionFor('usage-override', s, o, env);\n }\n const spec = env.MODELS[stage];\n if (spec !== undefined)\n return decideFromSpec('explicit-models', stage, spec, env);\n if (!routingRequested(env))\n return { opts: {}, spec: null, reason: 'routing-not-requested' };\n if (stage === 'code' && (env.CODER === 'codex' || env.CODER === 'codex-fallback')) {\n const s = resolveCoderSpec(env);\n return decisionFor('coder-knob-codex', s, specToOpts(s, env), env);\n }\n if (stage === 'plan' && env.PLANNER === 'codex') {\n const s = 'codex:' + env.CODEX_MODEL + ':high';\n return decisionFor('planner-knob-codex', s, specToOpts(s, env), env);\n }\n if (stage === 'qe') {\n const s = resolveQeSpec(env);\n return decisionFor(qeReasonForFamilies(coderIsCodex(env), s), s, specToOpts(s, env), env);\n }\n const resolvedPrimary = env.primary || 'claude';\n const cell = budgetTable(resolvedPrimary, resolveBudgetMode(env.budget), env)[stage];\n if (cell !== undefined)\n return decideFromSpec('budget-table-cell', stage, cell, env);\n return decideFromSpec('default-models', stage, DEFAULT_MODELS[stage], env);\n}\nfunction modelLabel(opts) {\n if (opts && opts.agentType === 'codex:codex-rescue') {\n const base = 'codex:' + opts.codexModel + ':' + opts._reasoning;\n return opts._usageSwitched ? base + ' (usage-switched)' : base;\n }\n if (opts && opts.model)\n return opts.model;\n return 'session';\n}\nfunction stageLabel(base, opts) {\n const m = modelLabel(opts);\n return m === 'session' ? base : base + ' · ' + m;\n}\nfunction mergeOpts(base, extra) {\n const out = {};\n for (const k in base)\n out[k] = base[k];\n for (const k in extra)\n out[k] = extra[k];\n return out;\n}",
69
69
  },
70
70
  "usage-probes": {
71
71
  name: "usage-probes",
@@ -94,6 +94,15 @@ export const BLOBS: Record<string, LoopBlob> = {
94
94
  exports: ["CHALLENGE_QUESTIONS","CHALLENGE_VERDICT_SCHEMA","buildChallengeBrief","sanitizeFinding","sanitizeVerdict","findingsNeedingCrossValidation","confirmedVerdict","renderVerdict","pickAdversaryModel","classifyModelFamily"],
95
95
  code: "const SEV_RANK = { P0: 3, P1: 2, P2: 1 };\nconst VALID_CID = new Set(['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8']);\nconst isValidSeverity = (s) => s === 'P0' || s === 'P1' || s === 'P2';\nconst CHALLENGE_QUESTIONS = Object.freeze([\n Object.freeze({\n id: 'C1',\n title: 'Architecture anti-cement',\n prompt: 'Does this plan cement a NEW bad pattern, a wrong boundary, or a shortcut that later work will be ' +\n 'forced to copy? Check it against the product map + vision. IMPORTANT: deviating from a pattern that ' +\n 'is REGISTERED in the accepted-degradations registry is NOT a finding — that debt is already owned. ' +\n 'A genuinely new degradation the plan introduces → name it and propose it for the registry.',\n }),\n Object.freeze({\n id: 'C2',\n title: 'Production-ready',\n prompt: 'Where would this fall over in production? Missing error handling, unhandled failure modes, ' +\n 'resource leaks, missing observability, config/secret handling, migration/rollback. Name the ' +\n 'concrete input or condition that breaks it, not a general worry.',\n }),\n Object.freeze({\n id: 'C3',\n title: 'Test sufficiency + honesty (both ways)',\n prompt: 'Attack the test plan from BOTH sides. Under-testing: which claim — especially a safety property the ' +\n 'ADR NAMES (\"never X\") — has no falsifying test? A test that cannot fail (a wrapper, a tautology, ' +\n 'asserting the mock) is not coverage. Over-testing: which tests are theater — restating the ' +\n 'implementation, testing the framework, brittle snapshots that verify nothing a user cares about?',\n }),\n Object.freeze({\n id: 'C4',\n title: 'Overengineering sweep',\n prompt: 'What in this plan is built for a requirement nobody stated? Speculative generality, an abstraction ' +\n 'with one caller, a config knob no one asked for, a plugin seam for a single case. For each: what is ' +\n 'the simpler thing that meets the ACTUAL requirement?',\n }),\n Object.freeze({\n id: 'C5',\n title: 'Silent decisions',\n prompt: 'Which load-bearing decisions did the plan make WITHOUT surfacing them as a decision? A default that ' +\n 'is really a policy, a chosen tradeoff presented as the only option, a dependency added in passing. ' +\n 'Each silent decision the owner did not get to refuse is a finding.',\n }),\n Object.freeze({\n id: 'C6',\n title: 'Runtime consistency',\n prompt: 'Will this behave consistently with how the rest of the system already works — same error shape, ' +\n 'same config source, same module/ESM conventions, same logging, same naming? Point to the specific ' +\n 'existing convention the plan contradicts.',\n }),\n Object.freeze({\n id: 'C7',\n title: 'Scope',\n prompt: 'Is the plan more than ~1.5× the size the request actually needs? If so, what is the concrete cut ' +\n 'list — which files/steps/abstractions to drop to hit the real requirement — and what is genuinely ' +\n 'load-bearing and must stay?',\n }),\n Object.freeze({\n id: 'C8',\n title: 'Executability',\n prompt: 'Could an executor who is NOT the plan author complete every step without coming back to ask what was ' +\n 'meant? Find the steps that are under-specified, assume unstated context, or hide a research task ' +\n 'behind an imperative verb (\"integrate X\", \"wire up Y\") with no concrete how.',\n }),\n]);\nconst CHALLENGE_VERDICT_SCHEMA = Object.freeze({\n type: 'object',\n required: ['findings', 'summary'],\n properties: {\n findings: {\n type: 'array',\n items: {\n type: 'object',\n required: ['c', 'severity', 'title', 'why'],\n properties: {\n c: { type: 'string', enum: ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8'] },\n severity: { type: 'string', enum: ['P0', 'P1', 'P2'] },\n title: { type: 'string' },\n why: { type: 'string', description: 'concrete failing input/condition, not a general worry' },\n where: { type: 'string', description: 'plan section / file:line if locatable' },\n },\n },\n },\n summary: { type: 'string' },\n },\n});\nconst HR = '─'.repeat(72);\nconst section = (title, body) => body === undefined ? `## ${title}\\n(not provided — panel runs with less calibration)\\n` : `## ${title}\\n${body}\\n`;\nfunction buildChallengeBrief(ctx) {\n const lines = [];\n lines.push('# CHALLENGE PANEL — adversarial plan-gate');\n lines.push('');\n lines.push('You are a FRESH adversarial reviewer. You did NOT write this plan. Your job is to BREAK it, not to ' +\n 'confirm it — find the concrete way each answer fails, or state plainly that you could not. A finding ' +\n 'is a specific failing input/condition/omission, never a general worry. Every P0/P1 you raise will be ' +\n 'independently cross-validated, so do not pad — theory that cannot be reproduced will be dropped.');\n lines.push('');\n lines.push(HR);\n lines.push(section('PLAN UNDER REVIEW (' + ctx.planPath + ')', ctx.plan === '' ? undefined : ctx.plan));\n lines.push(section('PRODUCT VISION (boundaries + principles)', ctx.vision));\n lines.push(section('TESTING POLICY (what \"done\" + honest tests mean here)', ctx.testing));\n lines.push(section('PRODUCT MAP (subsystems + existing conventions)', ctx.map));\n lines.push(section('ACCEPTED-DEGRADATIONS REGISTRY (deviating from THESE is NOT a finding)', ctx.degradations));\n lines.push(section('CODE HINTS', ctx.codeHints));\n lines.push(HR);\n lines.push('');\n lines.push('## Ask each question in \"break it, don\\'t confirm it\" mode');\n for (const q of CHALLENGE_QUESTIONS) {\n lines.push('');\n lines.push(`### ${q.id} — ${q.title}`);\n lines.push(q.prompt);\n }\n lines.push('');\n lines.push(HR);\n lines.push('## Output');\n lines.push('Return findings tagged by C-number with severity P0 (would ship a serious defect / cements bad ' +\n 'architecture), P1 (real gap, fix before code), or P2 (worth noting). For each: a concrete `why` ' +\n '(the failing input/condition) and `where` if locatable. Then a one-paragraph `summary`. This gate ' +\n 'ADVISES — it does not block; the owner decides.');\n lines.push('');\n lines.push('Verdict JSON schema: ' + JSON.stringify(CHALLENGE_VERDICT_SCHEMA));\n return lines.join('\\n');\n}\nfunction sanitizeFinding(raw) {\n if (raw === null || typeof raw !== 'object')\n return null;\n const r = raw;\n if (typeof r.c !== 'string' || !VALID_CID.has(r.c))\n return null;\n if (!isValidSeverity(r.severity))\n return null;\n if (typeof r.title !== 'string' || r.title === '')\n return null;\n if (typeof r.why !== 'string' || r.why === '')\n return null;\n const out = { c: r.c, severity: r.severity, title: r.title, why: r.why };\n return typeof r.where === 'string' && r.where !== '' ? { ...out, where: r.where } : out;\n}\nfunction sanitizeVerdict(raw) {\n if (raw === null || typeof raw !== 'object')\n return null;\n const r = raw;\n if (!Array.isArray(r.findings))\n return null;\n const findings = r.findings.map(sanitizeFinding).filter((f) => f !== null);\n return { findings, summary: typeof r.summary === 'string' ? r.summary : '' };\n}\nfunction findingsNeedingCrossValidation(v) {\n return [...v.findings]\n .filter((f) => f.severity === 'P0' || f.severity === 'P1')\n .sort(cmpFinding);\n}\nfunction cmpFinding(a, b) {\n const s = SEV_RANK[b.severity] - SEV_RANK[a.severity];\n if (s !== 0)\n return s;\n if (a.c !== b.c)\n return a.c < b.c ? -1 : 1;\n return a.title < b.title ? -1 : a.title > b.title ? 1 : 0;\n}\nfunction confirmedVerdict(v, realFlags) {\n const need = findingsNeedingCrossValidation(v);\n const kept = v.findings.filter((f) => f.severity === 'P2');\n need.forEach((f, i) => {\n if (realFlags[i] === true)\n kept.push({ ...f, crossValidated: true });\n });\n kept.sort(cmpFinding);\n return { findings: kept, summary: v.summary };\n}\nfunction renderVerdict(v) {\n if (v.findings.length === 0) {\n return `Challenge panel: no cross-validated findings. ${v.summary}`.trim();\n }\n const byId = (s) => v.findings.filter((f) => f.severity === s).sort(cmpFinding);\n const out = ['Challenge panel verdict (advisory — you decide):', ''];\n for (const sev of ['P0', 'P1', 'P2']) {\n const group = byId(sev);\n if (group.length === 0)\n continue;\n out.push(`### ${sev} (${group.length})`);\n for (const f of group) {\n const cv = f.crossValidated ? ' ✓cross-validated' : '';\n const where = f.where ? ` [${f.where}]` : '';\n out.push(`- ${f.c} ${f.title}${where}${cv}`);\n out.push(` why: ${f.why}`);\n }\n out.push('');\n }\n out.push(v.summary);\n return out.join('\\n').trim();\n}\nfunction pickAdversaryModel(plannerModel) {\n const fam = classifyModelFamily(plannerModel);\n if (fam === 'claude')\n return { model: 'codex', note: `plan authored on ${plannerModel} (Claude) → Codex adversary (cross-family)` };\n if (fam === 'openai')\n return { model: 'claude', note: `plan authored on ${plannerModel} (OpenAI/Codex) → fresh Claude adversary (cross-family)` };\n return { model: 'claude', note: `plan author family UNKNOWN (${plannerModel}) → Claude adversary by default; verify it is cross-family before trusting the verdict` };\n}\nfunction classifyModelFamily(model) {\n const m = String(model).toLowerCase();\n if (/claude|opus|sonnet|haiku|fable/.test(m))\n return 'claude';\n if (/codex|gpt|openai|\\bo[1-9]\\b/.test(m))\n return 'openai';\n return 'unknown';\n}",
96
96
  },
97
+ "stage-line": {
98
+ name: "stage-line",
99
+ version: "2.0.0",
100
+ contentHash: "3b921b7b8a38edc8ed7ec544eb792e1f14db1872b87d9bcf3c2daa5839c90675",
101
+ sourcePath: "packages/@dzhechkov/harness-core/src/stage-line.ts",
102
+ requires: [],
103
+ exports: ["STAGE_LINE_REASON_LABELS","STAGE_LINE_BARRIER_STAGES","renderStageIntentLine","renderStageOutcomeLine","renderStageLine"],
104
+ code: "const STAGE_LINE_REASON_LABELS = {\n 'usage-override': 'usage override (Claude limit pressure)',\n 'explicit-models': 'explicit args.models',\n 'routing-not-requested': 'routing not requested',\n 'coder-knob-codex': 'coder knob = codex',\n 'planner-knob-codex': 'planner knob = codex',\n 'qe-cross-family': 'cross-family QE (the coder never self-reviews)',\n 'budget-table-cell': 'budget table cell',\n 'default-models': 'default models table',\n 'codex-id-substituted': 'unknown codex id — substituted',\n 'spec-unrecognised': 'unrecognised spec — session-inherited',\n 'coder-fallback': 'coder fallback ladder',\n 'codex-unsupported-at-dispatch': 'codex unsupported at this dispatch — Claude',\n 'fallback-after-no-deliverable': 'fallback — the previous rung delivered nothing',\n 'precision-second-pass': 'independent precision second pass',\n 'auto-cost': 'learned-cost routing',\n 'qe-same-family-degraded': 'same-family QE — cross-family review NOT obtained',\n 'challenge-panel': 'adversarial plan-gate panel',\n 'codex-probe-failed': 'codex probe found no usable id — Claude',\n 'codex-refused-before-dispatch': 'codex refused before dispatching — nothing ran',\n 'fallback-rung': 'fallback rung (prior outcome reported separately)',\n};\nconst STAGE_LINE_BARRIER_STAGES = {\n code: 1,\n plan: 1,\n requirements: 1,\n adr: 1,\n ideation: 1,\n architecture: 1,\n};\nfunction renderStagePrefix(stage, decision) {\n let model = 'session';\n if (decision && decision.spec)\n model = decision.spec;\n return '▸ ' + stage + ' · ' + model;\n}\nfunction renderStageIntentLine(stage, decision) {\n let signature = '';\n if (decision) {\n const labelled = STAGE_LINE_REASON_LABELS[decision.reason];\n if (labelled)\n signature = labelled;\n }\n if (!signature) {\n signature = 'unlabelled branch';\n if (decision && decision.reason)\n signature = 'unlabelled branch: ' + decision.reason;\n }\n let line = renderStagePrefix(stage, decision) + ' · ' + signature;\n if (STAGE_LINE_BARRIER_STAGES[stage] && decision && decision.opts && decision.opts.agentType === 'codex:codex-rescue') {\n line = line + ' · landed barrier';\n }\n return line + ' · intent';\n}\nfunction renderStageOutcomeLine(stage, decision, outcome) {\n let model = 'session';\n if (decision && decision.spec)\n model = decision.spec;\n let label = 'dispatched';\n if (outcome && outcome.state === 'probe-failed')\n label = 'probe found no usable model';\n else if (outcome && outcome.state === 'refused-before-dispatch')\n label = 'refused before dispatch';\n let line = '◆ ' + stage + ' · ' + model + ' · outcome: ' + label;\n if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason === 'codex-unsupported-at-dispatch') {\n line = line + ' · codex agent type unsupported';\n }\n else if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason) {\n line = line + ' · reason: ' + outcome.reason;\n }\n return line;\n}\nfunction renderStageLine(stage, decision) {\n const intent = renderStageIntentLine(stage, decision);\n return intent.slice(0, intent.length - ' · intent'.length);\n}",
105
+ },
97
106
  "trace": {
98
107
  name: "trace",
99
108
  version: "1.1.0",
package/src/loop-lint.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { createHash } from 'node:crypto';
25
- import { toLintProjection, JOIN_POLICIES, QUORUM_RE, type LoopPlan, type LintProjection } from './loop-plan.js';
25
+ import { toLintProjection, stepIdent, JOIN_POLICIES, QUORUM_RE, type LoopPlan, type LintProjection } from './loop-plan.js';
26
26
  import type { LoopBlob } from './loop-blobs.generated.js';
27
27
 
28
28
  export type LintVerdict = 'pass' | 'fail' | 'inconclusive';
@@ -584,6 +584,29 @@ export const TOOL_PERIMETER_ENTRY_RE = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)+$/;
584
584
  * exactly where cfr-pipeline runs it. Plan-less scripts report `inconclusive` (`no-plan-binding`)
585
585
  * like every other plan-anchored rule — never a silent pass.
586
586
  */
587
+ /**
588
+ * Текст массива промпта одного шага: содержимое `const P_<ident> = [ … ]` со сбалансированными
589
+ * скобками. `null`, если такого массива в скрипте нет.
590
+ *
591
+ * Скобки считаются, а не ищется первая `]`: строки договора содержат запятые и текст, а соседний
592
+ * шаг начинается сразу за концом массива — обрыв по первой скобке склеил бы соседей.
593
+ */
594
+ function promptArrayRegion(code: string, ident: string): string | null {
595
+ const head = new RegExp(`const\\s+P_${ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*\\[`).exec(code);
596
+ if (head === null) return null;
597
+ const open = head.index + head[0].length - 1;
598
+ let depth = 0;
599
+ for (let i = open; i < code.length; i += 1) {
600
+ const ch = code[i];
601
+ if (ch === '[') depth += 1;
602
+ else if (ch === ']') {
603
+ depth -= 1;
604
+ if (depth === 0) return code.slice(open + 1, i);
605
+ }
606
+ }
607
+ return null; // массив не закрыт — область не установлена, и выдумывать её нельзя
608
+ }
609
+
587
610
  function ruleToolPerimeterDeclared(ctx: Ctx): LintFinding[] {
588
611
  if (ctx.projection === null) return noPlan('tool-perimeter-declared');
589
612
  const sev: LintSeverity = ctx.mode === 'require-plan' ? 'fail' : 'warn';
@@ -604,13 +627,35 @@ function ruleToolPerimeterDeclared(ctx: Ctx): LintFinding[] {
604
627
  }
605
628
  seen.add(entry);
606
629
  }
607
- // script cross-check: the rendered contract line must agree with the DECLARED array — a plan
608
- // edit that never re-rendered would otherwise pass on the plan half alone.
609
- if (f.tools.length > 0) {
610
- const expected = 'declared MCP tool allowlist (plan tools): ' + f.tools.join(', ') + ' use NOTHING outside it.';
611
- if (!ctx.script.includes(expected)) {
612
- out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: the rendered script carries no contract line matching the declared perimeter [${f.tools.join(', ')}] the script is stale against the plan, or the perimeter is decorative`, anchor: f.id });
630
+ // ПЕРЕКРЁСТНАЯ ПРОВЕРКА СО СКРИПТОМ В ОБЛАСТИ ЭТОГО ШАГА И БЕЗ КОММЕНТАРИЕВ.
631
+ //
632
+ // Три слабости прежней редакции, все ПОДТВЕРЖДЕНЫ ревью (бэклог 6b351420) и воспроизведены:
633
+ // (а) проверка шла только при `tools.length > 0` сжатие периметра до `[]` без ре-рендера
634
+ // оставляло в скрипте СТАРУЮ строку договора, и правило молчало;
635
+ // (б) `ctx.script.includes` искал ГЛОБАЛЬНОпотерянная строка одного шага удовлетворялась
636
+ // одинаковым периметром ДРУГОГО шага;
637
+ // (в) искали в `ctx.script`, а не в `ctx.code`, поэтому строка договора, перенесённая в
638
+ // `//`-комментарий, проходила как настоящая.
639
+ //
640
+ // ОБЛАСТЬ ШАГА — ЭТО ЕГО МАССИВ ПРОМПТА `const P_<ident> = [ … ]`, а НЕ область между
641
+ // маркерами `BEGIN/END step:<id>`. Измерено на отрендеренном плане: строка договора шага
642
+ // `lane`, раздаваемого веером, лежит в области шага `fan` — привязка к маркерам дала бы
643
+ // ложное нарушение на каждом веерном шаге.
644
+ const PERIMETER_PREFIX = 'declared MCP tool allowlist (plan tools): ';
645
+ const region = promptArrayRegion(ctx.code, stepIdent(f.id));
646
+ if (region === null && f.tools.length > 0) {
647
+ // Периметр ОБЪЯВЛЕН, а сверять его не с чем: у шага нет массива промпта в скрипте. Для шага
648
+ // с ПУСТЫМ периметром отсутствие области — не находка: противоречить там нечему, и краснеть
649
+ // на всяком ненарендеренном скрипте значило бы изобретать нарушения.
650
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: the script carries no \`const P_${stepIdent(f.id)} = [ … ]\` prompt array — a declared perimeter [${f.tools.join(', ')}] cannot be cross-checked against it at all`, anchor: f.id });
651
+ } else if (region !== null && f.tools.length > 0) {
652
+ const expected = PERIMETER_PREFIX + f.tools.join(', ') + ' — use NOTHING outside it.';
653
+ if (!region.includes(expected)) {
654
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: the step's own prompt carries no contract line matching the declared perimeter [${f.tools.join(', ')}] — the script is stale against the plan, or the perimeter is decorative`, anchor: f.id });
613
655
  }
656
+ } else if (region !== null && region.includes(PERIMETER_PREFIX)) {
657
+ // Периметр сжали до пустого, а строка договора осталась: скрипт разрешает больше, чем план.
658
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: the plan declares an EMPTY tool perimeter, but the step's prompt still carries a tool-allowlist contract line — the script was not re-rendered and grants more than the plan allows`, anchor: f.id });
614
659
  }
615
660
  }
616
661
  return out;
@@ -34,7 +34,72 @@ export type InterfaceGraph = ReadonlyMap<string, readonly GraphField[]>;
34
34
 
35
35
  /** Brace-matched interface extraction. A regex-only scan truncates at the first nested brace
36
36
  * (inline object fields are everywhere in this file), so bodies are cut by depth counting. */
37
- export function parseInterfaceGraph(source: string): InterfaceGraph {
37
+ /**
38
+ * Убрать из текста КОММЕНТАРИИ и СОДЕРЖИМОЕ строковых литералов, сохранив длину и переводы строк.
39
+ *
40
+ * ЗАЧЕМ, с воспроизведёнными случаями (ИЗМЕРЕНО 2026-09-04, бэклог f23e97dc). Разбор считал скобки
41
+ * и точки с запятой по СЫРОМУ тексту, и четыре конструкции давали ТИХО НЕВЕРНЫЙ граф — а граф
42
+ * решает, достижим ли интерфейс, то есть живой он или мёртвый:
43
+ *
44
+ * • `interface X { note: "смотри Y"; }` — упоминание Y ВНУТРИ строки становилось НАСТОЯЩИМ
45
+ * ребром. Мёртвый интерфейс выглядел живым: ложно-зелёное в проверке достижимости.
46
+ * • `interface X { note: "a; ref: Y"; }` — точка с запятой внутри строки резала запись, и
47
+ * появлялось призрачное поле `ref` со своим ребром.
48
+ * • `interface X { // закрывает } раньше\n ref: Y; }` — закрывающая скобка в комментарии
49
+ * обрезала тело, и настоящее ребро ТЕРЯЛОСЬ: живой интерфейс выглядел мёртвым.
50
+ * • `interface X { kind: "{"; ref: Y; }` — открывающая скобка в строке раздувала тело так, что
51
+ * интерфейс вовсе исчезал из графа.
52
+ *
53
+ * ДЛИНА СОХРАНЯЕТСЯ НАМЕРЕННО: разбор ниже работает индексами по этому же тексту, и замена на
54
+ * строку другой длины сдвинула бы каждую последующую позицию. Переводы строк сохраняются, чтобы
55
+ * номера строк оставались верными, если их когда-нибудь понадобится сообщить.
56
+ */
57
+ function stripCommentsAndStrings(source: string): string {
58
+ const out = source.split('');
59
+ const blank = (from: number, to: number): void => {
60
+ for (let i = from; i < to && i < out.length; i += 1) {
61
+ if (out[i] !== '\n') out[i] = ' ';
62
+ }
63
+ };
64
+ let i = 0;
65
+ while (i < source.length) {
66
+ const two = source.slice(i, i + 2);
67
+ if (two === '//') {
68
+ const end = source.indexOf('\n', i);
69
+ blank(i, end === -1 ? source.length : end);
70
+ i = end === -1 ? source.length : end;
71
+ continue;
72
+ }
73
+ if (two === '/*') {
74
+ const end = source.indexOf('*/', i + 2);
75
+ const stop = end === -1 ? source.length : end + 2;
76
+ blank(i, stop);
77
+ i = stop;
78
+ continue;
79
+ }
80
+ const ch = source[i];
81
+ if (ch === '"' || ch === "'" || ch === '`') {
82
+ let j = i + 1;
83
+ while (j < source.length) {
84
+ if (source[j] === '\\') { j += 2; continue; } // экранированный символ не закрывает строку
85
+ if (source[j] === ch) break;
86
+ j += 1;
87
+ }
88
+ // Гасится СОДЕРЖИМОЕ, а кавычки остаются: тип `"a" | "b"` обязан выглядеть как тип, а не
89
+ // как склеенное слово.
90
+ blank(i + 1, Math.min(j, source.length));
91
+ i = Math.min(j + 1, source.length);
92
+ continue;
93
+ }
94
+ i += 1;
95
+ }
96
+ return out.join('');
97
+ }
98
+
99
+ export function parseInterfaceGraph(rawSource: string): InterfaceGraph {
100
+ // Комментарии и содержимое строк гасятся ДО подсчёта скобок: иначе `}` в комментарии обрезает
101
+ // тело, `{` в строке его раздувает, а имя интерфейса внутри строки становится ребром графа.
102
+ const source = stripCommentsAndStrings(rawSource);
38
103
  const names = new Set<string>();
39
104
  const headRe = /(?:^|\n)\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/g;
40
105
  for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) names.add(m[1]!);
package/src/loop-trace.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * consumed by `dz workflow-trace` and by the fitness suite (same runInvariants, two call sites).
24
24
  */
25
25
 
26
+ import { isNonRunRow } from './ledger-backfill.js';
26
27
  import type { TraceProjection } from './loop-plan.js';
27
28
 
28
29
  /** Blob version stamp for the emitter half (read by scripts/gen-loop-blobs.mjs). */
@@ -941,10 +942,21 @@ export function assembleTimeline(input: {
941
942
  }
942
943
  }
943
944
  if (input.ledger) {
945
+ // The source is declared even when every row is filtered out: the file WAS read, and saying so
946
+ // is the difference between "no cost rows" and "no ledger".
944
947
  sources.push('ledger');
945
948
  for (const line of input.ledger.split('\n')) {
946
949
  const t = line.trim();
947
- if (t !== '') rows.push({ seq: 0, kind: 'ledger', label: 'cost', detail: t.slice(0, 160), wallTime: null });
950
+ if (t === '') continue;
951
+ // AM-4: `kind`-carrying rows are per-phase TELEMETRY, not cost. Labelling one `cost` in a run
952
+ // timeline states a cost claim the row does not make — MEASURED 2026-09-06 as a real defect,
953
+ // not a hypothetical. A malformed line is still shown: unparseable is not "not a cost row".
954
+ try {
955
+ const parsed: unknown = JSON.parse(t);
956
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
957
+ && isNonRunRow(parsed as Record<string, unknown>)) continue;
958
+ } catch { /* unparseable → keep it visible, exactly as before */ }
959
+ rows.push({ seq: 0, kind: 'ledger', label: 'cost', detail: t.slice(0, 160), wallTime: null });
948
960
  }
949
961
  }
950
962
  if (Array.isArray(input.usageEvents)) {
@@ -8,8 +8,8 @@
8
8
  * obvious way to write it and the wrong one.
9
9
  *
10
10
  * What differs between the targets is ONLY the attribution predicate, so that is the parameter:
11
- * the Claude path passes its historical substring list verbatim (bytes must not move — AM-3), the
12
- * Codex path passes sha-over-manifest (ADR-001 §3: dz never deletes what it cannot prove it wrote).
11
+ * the Claude path passes its historical command identities, the Codex path passes sha-over-manifest
12
+ * (ADR-001 §3), and both salvage foreign handlers that share a matcher group with an owned handler.
13
13
  *
14
14
  * The plan (AM-37) is explicit that the extracted block has **three** outputs, not one: the merged
15
15
  * body, the REPORT tail string, and the no-write path (`changed === false`). A golden test on the
@@ -69,10 +69,9 @@ export interface MergeManagedHookOptions {
69
69
  * Per-HANDLER salvage for an entry `isManaged` claimed. Given an OWNED entry, return it rebuilt
70
70
  * from only the handlers that are NOT ours, or `null` when every handler was ours.
71
71
  *
72
- * Optional, and absent means the historical whole-entry behaviour the Claude path passes
73
- * nothing and is byte-identical to before (AM-3). The Codex path passes it because attribution at
74
- * matcher-group granularity deleted a foreign handler that merely shared a group with dz's
75
- * (independent review, finding 6).
72
+ * Optional, and absent means the historical whole-entry behaviour. Both live registry paths pass
73
+ * it because attribution at matcher-group granularity would delete a foreign handler that merely
74
+ * shared a group with dz's (Codex finding 6; Claude delivery review round 19).
76
75
  */
77
76
  readonly retainForeign?: (entry: ManagedHookEntry, event: string) => ManagedHookEntry | null;
78
77
  /** e.g. `'agentdb'` → `merged agentdb hooks (user hooks preserved)`. */
@@ -54,8 +54,12 @@ const RULE_TEXT =
54
54
 
55
55
  /** `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`; claude ids pass through; unknown → null. */
56
56
  export function normalizeModelId(raw: unknown): string | null {
57
- if (typeof raw !== 'string' || raw === '') return null;
58
- let id = raw.replace(/ \(usage-switched\)$/, '').trim();
57
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
58
+ // ОБРЕЗКА ИДЁТ ПЕРВОЙ. Прежде метка снималась якорем `$` по СЫРОЙ строке, и один хвостовой
59
+ // пробел — `codex:gpt-5.5:xhigh (usage-switched) ` — делал весь образец неопознанным (null),
60
+ // то есть телеметрия молча теряла запись. ИЗМЕРЕНО 2026-09-04: без пробела «gpt-5.5», с
61
+ // пробелом null.
62
+ let id = raw.trim().replace(/\s*\(usage-switched\)$/, '').trim();
59
63
  const codex = /^codex:([^:]+)(?::[a-z]+)?$/.exec(id);
60
64
  if (codex !== null) id = codex[1]!;
61
65
  return COST_LADDER.some((r) => r.id === id) ? id : null;
@@ -94,7 +98,14 @@ export function harvestStageOutcomes(records: readonly unknown[]): Harvest {
94
98
  if (grade === null) { skipped.noGrade += 1; continue; }
95
99
  // Full canonical JSON is intentional: the former 40-character prefix merged distinct records
96
100
  // whose stage names shared a long prefix, manufacturing idempotency across different runs.
97
- const runId = typeof source['runId'] === 'string' ? source['runId'] : `models:${JSON.stringify(modelsUsed)}`;
101
+ // ПУСТОЙ runId НЕ ИДЕНТИФИКАТОР. Прежде проверка была `typeof === 'string'`, и пустая строка
102
+ // принималась: ВСЕ такие записи схлопывались в один «прогон», а число прогонов и признак
103
+ // «скормлено» считались по нему. Отсутствующий и пустой теперь одинаково уходят на запасной
104
+ // ключ по составу моделей.
105
+ const rawRunId = source['runId'];
106
+ const runId = typeof rawRunId === 'string' && rawRunId.trim() !== ''
107
+ ? rawRunId.trim()
108
+ : `models:${JSON.stringify(modelsUsed)}`;
98
109
  const ts = typeof source['timestamp'] === 'string' ? source['timestamp'] : null;
99
110
  const tier = typeof result['tier'] === 'string' && result['tier'] !== '' ? result['tier'] : 'unknown';
100
111
  let contributed = false;