@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
@@ -14,16 +14,21 @@
14
14
  * is taught silently but NOT drilled — no nagging on a one-off. Drills are for recurrent patterns only.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
18
- import { join } from 'node:path';
17
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
18
+ import { join, basename, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
 
21
+ import { withNamedLockSync, NamedLockTimeoutError } from './named-lock.js';
22
+
21
23
  export interface SessionEvent {
22
24
  readonly kind: 'user' | 'assistant' | 'tool';
23
25
  readonly text: string;
24
26
  readonly tool?: string; // tool_use name (Read/Edit/Write/Bash) for a call
25
27
  readonly file?: string; // file path arg for a Read/Edit/Write
26
28
  readonly ok?: boolean; // for a tool RESULT: false ⇒ error/failure
29
+ /** `tool_use.id` on a call, `tool_result.tool_use_id` on its result — the pairing key that lets
30
+ * the fold tell WHICH executed command a failure belongs to (ADR-004). Absent ⇒ no pairing. */
31
+ readonly toolUseId?: string;
27
32
  }
28
33
 
29
34
  export interface ProcessSignature {
@@ -87,6 +92,12 @@ export const PROCESS_SIGNATURES: readonly ProcessSignature[] = [
87
92
  checklist: 'Run the tests/build (and read the output) BEFORE `git commit`. A green commit you did not verify is a guess.',
88
93
  skill: 'validate',
89
94
  },
95
+ {
96
+ id: 'correction-narrated-without-teach',
97
+ label: 'admitted an error in chat but never recorded it with `dz teach`',
98
+ socratic: 'You wrote «моя ошибка» / "correction of record" — where is the `dz teach` from that same turn? Predict what the lesson text (with its reproducer) should have been, then check the store.',
99
+ checklist: 'Narrating and recording are ONE action: in the SAME turn as the admission, run `dz teach "<lesson + reproducer>"`, then verify with a control `dz recall <keywords>` that surfaces it as a top hit. Chat is layer 4 — it compacts away; the store does not.',
100
+ },
90
101
  ];
91
102
  for (const s of PROCESS_SIGNATURES) Object.freeze(s);
92
103
  Object.freeze(PROCESS_SIGNATURES);
@@ -113,6 +124,113 @@ const CODE_FILE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|c|h|cc|cpp|css|s
113
124
  // and `git log --grep commit` do NOT count (cross-model QE).
114
125
  const GIT_COMMIT_RE = /(?:^|&&|\|\||;|\|)\s*git(?:\s+-[A-Za-z-]+(?:\s+\S+)?)*\s+commit\b/;
115
126
 
127
+ // ── correction-narrated-without-teach (feature narrated-error-must-be-taught, ADR-001) ─────────────
128
+ // The owner rule of 2026-08-28: an error ADMITTED in chat must be TAUGHT — narration without a
129
+ // `dz teach` is the rake the owner rule was written for (the record of the pipe-exit-code repeat is
130
+ // CLAUDE.md § «Narrated errors are taught in the same turn» — a chat observation, not a re-runnable
131
+ // measurement, so no count is claimed here). Cyrillic-safe:
132
+ // leading letter-class lookbehind, no `\b` (does not anchor Cyrillic), inflected stems tolerated
133
+ // (ошибся/ошибалась/ошиблись; моей/мою ошибку). HONEST LIMITS, stated rather than papered over:
134
+ // · quotes are recognised ONLY as `>`-prefixed lines gated by a small fixed attribution list
135
+ // («ревью», reviewer, Codex, grade) — a review pasted WITHOUT `>` markers still fires (we accept
136
+ // that over missing real admissions), and any other quoting style is invisible to the heuristic;
137
+ // · the "REFUTED" possessive gate is per-EVENT, not per-sentence — a "my" from another clause can
138
+ // arm it; and it is case-sensitive by design (lowercase "refuted" quotes ordinary prose);
139
+ // · EN coverage is deliberately narrow ("correction of record", possessive-gated "REFUTED"): a
140
+ // plain "I was wrong" does not fire — extend only on measured evidence, precision first.
141
+ // Round 3, P1-1: the `я ошиб\w*` branch used to match the NOUN — «Я ошибку валидации исправил» is a
142
+ // COMPLETION REPORT, the commonest sentence an assistant writes, and it armed a teaching debt. The
143
+ // branch is now an ALLOWLIST of confession forms: the reflexive verb (ошибся/ошиблась/ошиблись), its
144
+ // imperfective and present (ошибался/ошибалась/ошибались/ошибаюсь) and the adverb (ошибочно). The
145
+ // noun survives ONLY in the possessive form the owner rule names literally, «моя ошибка» — a first
146
+ // branch that was always separate. MEASURED, reproducer
147
+ // `node features/narrated-error-must-be-taught/07_code_changes/field-admission-verb-probe.mjs`:
148
+ // over 95 transcripts the old and the new branch fire on the SAME 126 of 982 assistant texts — the
149
+ // tightening costs zero recall in the field. The defect itself is proven synthetically, on the
150
+ // reviewer's own string and two siblings (`--synthetic`), because this corpus happens to contain no
151
+ // instance of the noun form; that is stated rather than dressed up as a measured win.
152
+ // The adverb is in the allowlist for a measured reason: a verb-only draft dropped exactly one real
153
+ // field admission («я ошибочно склеил их»), quoted by `--removed`.
154
+ const ADMISSION_RE = /(?<![a-zа-яё])(?:мо(?:я|ей|ю|и|их)\s+ошибк\w*|я\s+(?:ошиб(?:ся|лась|лись)|ошиба(?:юсь|лся|лась|лись)|ошибочно)(?![a-zа-яё])|я\s+был[аи]?\s+неправ\w*|мой\s+диагноз[^.\n]{0,80}?невер\w*|correction of record)/i;
155
+ const FIRST_PERSON_RE = /(?<![a-zа-яё])(?:my|мой|моя|моё|мои|мою|моей|моего|моих)(?![a-zа-яё])/i;
156
+ const REFUTED_RE = /\bREFUTED\b/; // case-sensitive — see limits above
157
+ const ATTRIBUTION_RE = /(?<![a-zа-яё])(?:ревью|reviewer|codex|grade)(?![a-zа-яё])/i;
158
+ // A teach MENTION in event text: `dz teach`, `…/bin.js teach` (quoted path tolerated), `$DZ teach`.
159
+ // Used by the DETECTOR only (anti-accusation register — prefer a miss to a false "you never
160
+ // taught"); the sentinel requires the strict command form below.
161
+ const TEACH_RE = /(?:(?<![\w.$-])dz|bin\.js['"]?|\$DZ)\s+teach(?![a-zа-яё])/i;
162
+ // A teach INVOCATION as an EXECUTED COMMAND (cross-family QE P1-2): anchored at a COMMAND BOUNDARY
163
+ // (start, or after && ; | ||), optional `node` + path prefix, and accepted ONLY from a Bash
164
+ // tool_use event — never from tool_result OUTPUT and never from prose. Without the anchor,
165
+ // `echo "dz teach later"` / `grep "dz teach"` / a tool result echoing the phrase all CLEARED the
166
+ // sentinel with no lesson stored — a self-absolving loophole. Honest limits: this is a regex over
167
+ // a shell string, not a shell parser — a quoted `"…; dz teach …"` still matches (boundary chars
168
+ // inside quotes), and a `VAR=1 dz teach` env-prefix form does not; both are accepted as-is.
169
+ // ADR-005: the `m` flag makes the START OF EVERY LINE a boundary too, so a multi-line Bash command
170
+ // whose second line IS the teach (`cd /b\ndz teach …`; the dominant field form `DZ=…; B=…\n$DZ teach
171
+ // … --project $B`) pays. MEASURED 2026-09-06 over 95 transcripts — reproducer:
172
+ // `node features/narrated-error-must-be-taught/07_code_changes/field-teach-boundary-probe.mjs`
173
+ // → of 448 teach-shaped Bash strings, 89 paid under the old single-line `/i` regex, 241 more pay
174
+ // only once `m` is on, and 118 stay refused for other reasons. The REFUSE side is unchanged
175
+ // (every P1-2 decoy still leaves the debt armed).
176
+ // Named, accepted residual: a heredoc BODY line beginning with `dz teach` now reads as a command and
177
+ // pays — a regex is not a shell parser; ADR-003 Option C (tokenizer) is the deferred fix.
178
+ const TEACH_CMD_RE = /(?:^|&&|\|\||;|\|)\s*(?:node\s+)?(?:[^\s&|;]*\/)?(?:dz|bin\.js['"]?|\$DZ)\s+teach(?![a-zа-яё])/im;
179
+ // R1 (fix round 2, cross-family review of the fix round — Codex `gpt-5.6-sol`): the command TEXT
180
+ // proves a teach stood at a command boundary, never that it RAN. The reviewer's reproducer
181
+ // `exit 0\ndz teach "never runs"` matched `TEACH_CMD_RE` and settled the debt with nothing stored.
182
+ // So the text stays NECESSARY and this receipt — a line `dz teach` itself prints once the store was
183
+ // written — is what is SUFFICIENT. The five shapes are quoted from live runs against a scratch store
184
+ // (2026-09-06): `Learned: "…"` + `Total patterns:` + `store (written):` on a new lesson,
185
+ // `↳ reinforced …` on `--reinforce`, `↳ mirrored to vector tier (…)` from the quarantine mirror, and
186
+ // `Imported N pattern(s) …` from `--from-json`. Deliberately NOT line-anchored: a `tool_result`
187
+ // reaches the fold either as raw stdout or as a JSON-stringified block list, and is truncated at
188
+ // 2000 chars, so `^` would miss the receipt in the second shape.
189
+ // MEASURED cost, reproducer
190
+ // `node features/narrated-error-must-be-taught/07_code_changes/field-teach-receipt-probe.mjs`:
191
+ // of 329 executed-teach-shaped Bash calls in 95 transcripts, 288 carry a receipt in their paired
192
+ // result, 41 do not (decoys, failures, and real teaches whose stdout was redirected away) and 4 are
193
+ // is_error. Those 41 now stay ARMED — a false ARM costs one visible directive, a false CLEAR costs
194
+ // an untaught error, and ADR-003 D2 ranks it that way.
195
+ const TEACH_RECEIPT_RE = /Learned:\s*"|\u21b3\s*(?:reinforced|mirrored to vector tier)|store \(written\):|Total patterns:|Imported \d+ pattern/;
196
+ // The injected debt directive itself names `dz teach` and lands in the transcript — without this
197
+ // exclusion the scanner would read its OWN directive as the payment (ADR-001 D4, the self-clearing
198
+ // trap). Events carrying the marker are excluded from BOTH teach and admission matching.
199
+ export const RETRO_DEBT_MARKER = '⚠ RETRO DEBT';
200
+
201
+ /** A teach MENTION in this event's text — never inside the injected directive (self-clear trap).
202
+ * Detector register only; the sentinel uses {@link isTeachCommand}. */
203
+ function isTeachText(text: string): boolean {
204
+ return !text.includes(RETRO_DEBT_MARKER) && TEACH_RE.test(text);
205
+ }
206
+
207
+ /** A REAL executed teach: a Bash tool_use whose COMMAND invokes teach at a command boundary.
208
+ * tool_result events carry no `tool` name in this stream, so echoed output can never qualify. */
209
+ function isTeachCommand(e: SessionEvent): boolean {
210
+ return e.kind === 'tool' && e.tool === 'Bash' && !e.text.includes(RETRO_DEBT_MARKER) && TEACH_CMD_RE.test(e.text);
211
+ }
212
+
213
+ /**
214
+ * The admission snippet of an assistant event, or null. Applies the quoted-review control and the
215
+ * REFUTED possessive gate; excludes events carrying the directive marker (they QUOTE the snippet).
216
+ */
217
+ function admissionSnippet(text: string): string | null {
218
+ if (text.includes(RETRO_DEBT_MARKER)) return null;
219
+ const m = ADMISSION_RE.exec(text);
220
+ const r = REFUTED_RE.exec(text);
221
+ const refutedAdmits = r !== null && FIRST_PERSON_RE.test(text.slice(0, r.index));
222
+ if (m === null && !refutedAdmits) return null;
223
+ if (ATTRIBUTION_RE.test(text)) {
224
+ // Attribution present: an admission counts only if a marker appears on an UNQUOTED line.
225
+ const unquoted = text
226
+ .split('\n')
227
+ .some((line) => !/^\s*>/.test(line) && (ADMISSION_RE.test(line) || (refutedAdmits && REFUTED_RE.test(line))));
228
+ if (!unquoted) return null;
229
+ }
230
+ const idx = m !== null ? m.index : (r as RegExpExecArray).index;
231
+ return text.slice(Math.max(0, idx - 30), idx + 170);
232
+ }
233
+
116
234
  /**
117
235
  * Detect PROCESS rakes over the event stream. PURE + deterministic. Conservative (high-precision): prefer a
118
236
  * miss to a false accusation (a wrong "you claimed done without testing" erodes trust worse than a miss).
@@ -129,7 +247,8 @@ export function detectProcessRakes(events: readonly SessionEvent[]): ProcessHit[
129
247
 
130
248
  // NB: no `didnt-read-before-edit` signature — the harness ENFORCES read-before-edit (an Edit fails
131
249
  // without a prior Read), so a genuine violation is near-impossible; that signal was pure artifact
132
- // (cross-session / bounded-window reads, 88 false hits on the dogfood) and was dropped after cross-model QE.
250
+ // (cross-session / bounded-window reads produced false hits on the dogfood run that run is not
251
+ // reproducible from this repo, so no count is quoted) and was dropped after cross-model QE.
133
252
  const editsPerFile = new Map<string, number>();
134
253
  const failedAfterEdit = new Set<string>(); // files that had a TEST failure after being edited
135
254
  let lastEditedFile: string | undefined;
@@ -137,9 +256,22 @@ export function detectProcessRakes(events: readonly SessionEvent[]): ProcessHit[
137
256
  let verifiedSinceCommit = false; // a test/build ran AFTER the last code change
138
257
  const TESTFAIL_RE = /\b(fail(ed|ing|s)?|assertion|assert|expected|not ok|panic|traceback|error ts\d|\d+ failed)\b/i;
139
258
 
259
+ // correction-narrated-without-teach is DEFERRED: an admission is settled only by a teach in a
260
+ // LATER event, so the verdict is known at end-of-stream. Conservative (anti-accusation): here ANY
261
+ // event text matching the teach pattern settles — prose "already taught with dz teach" suppresses
262
+ // the drill; the sentinel in foldAdmissionDebt is stricter (tool-only) for the opposite reason.
263
+ const admissions: Array<{ index: number; snippet: string }> = [];
264
+ let lastTeachIndex = -1;
265
+
140
266
  for (let i = 0; i < events.length; i++) {
141
267
  const e = events[i]!;
142
268
 
269
+ if (isTeachText(e.text)) lastTeachIndex = i;
270
+ if (e.kind === 'assistant' && !isTeachText(e.text)) {
271
+ const snippet = admissionSnippet(e.text);
272
+ if (snippet !== null) admissions.push({ index: i, snippet });
273
+ }
274
+
143
275
  // A code change marks the commit cycle dirty AND invalidates any earlier verification (it is now
144
276
  // stale — a test that ran BEFORE this edit did not verify it; cross-model QE High).
145
277
  if (e.kind === 'tool' && (e.tool === 'Edit' || e.tool === 'Write') && e.file && CODE_FILE_RE.test(e.file)) {
@@ -206,6 +338,11 @@ export function detectProcessRakes(events: readonly SessionEvent[]): ProcessHit[
206
338
  }
207
339
  }
208
340
 
341
+ // Resolve the deferred admissions: unpaid = no teach at a LATER index (its own event never
342
+ // contains a teach — excluded above), so `lastTeachIndex <= index` means the debt stood at
343
+ // end-of-stream. A teach BEFORE the admission does not settle it (acid A7).
344
+ for (const a of admissions) if (lastTeachIndex <= a.index) bump('correction-narrated-without-teach', a.snippet);
345
+
209
346
  const hits: ProcessHit[] = [];
210
347
  for (const sig of PROCESS_SIGNATURES) {
211
348
  const c = counts.get(sig.id);
@@ -280,7 +417,7 @@ export function renderRetro(retro: Retro): string {
280
417
 
281
418
  // ── Streaming I/O (top-level fs; never throws; NEVER slurps into structured memory beyond the line split) ──
282
419
 
283
- interface RawContentBlock { type?: string; text?: string; name?: string; input?: { file_path?: string; path?: string; command?: string }; content?: unknown; is_error?: boolean }
420
+ interface RawContentBlock { type?: string; text?: string; name?: string; id?: string; tool_use_id?: string; input?: { file_path?: string; path?: string; command?: string }; content?: unknown; is_error?: boolean }
284
421
  interface RawLine { type?: string; message?: { role?: string; content?: RawContentBlock[] | string } }
285
422
 
286
423
  /** Cap the read at the last N bytes for very large transcripts (a retro is about the RECENT session), so
@@ -309,13 +446,25 @@ const isObj = (x: unknown): x is Record<string, unknown> => x !== null && typeof
309
446
  /**
310
447
  * Parse a Claude Code JSONL transcript into a normalized event stream. Bad/`null`/malformed lines are
311
448
  * skipped (never throws — cross-model QE caught a crash on a `null` line and a `[null]` content block).
312
- * Text blocks WITHIN one message are merged into a SINGLE assistant/user event, so a multi-block turn
313
- * ("Done." + "Fixed.") counts as ONE claim, not two (else the anti-noise guarantee is defeated).
449
+ * ADJACENT text blocks WITHIN one message are merged into a single assistant/user event, so a
450
+ * multi-block turn ("Done." + "Fixed.") counts as ONE claim, not two (the anti-noise guarantee)
451
+ * but original CONTENT-BLOCK ORDER is preserved across tool blocks: admission text followed by a
452
+ * `dz teach` tool_use in the SAME message must settle the debt, which requires the teach event to
453
+ * land AFTER the text event (cross-family QE P1-1: the old flush-at-end put all text last, so a
454
+ * same-turn teach looked EARLIER than its admission and the happy path read as an unpaid debt).
314
455
  */
315
456
  export function streamSessionEvents(path: string): SessionEvent[] {
316
- const out: SessionEvent[] = [];
317
457
  const raw = readBounded(path);
318
- if (raw === '') return out;
458
+ if (raw === '') return [];
459
+ return parseSessionJsonl(raw);
460
+ }
461
+
462
+ /**
463
+ * Parse a JSONL CHUNK (whole file or an incremental tail of complete lines) into events. PURE.
464
+ * Extracted from streamSessionEvents so the per-turn tail scan parses only the new bytes.
465
+ */
466
+ export function parseSessionJsonl(raw: string): SessionEvent[] {
467
+ const out: SessionEvent[] = [];
319
468
  for (const line of raw.split('\n')) {
320
469
  const t = line.trim();
321
470
  if (t === '') continue;
@@ -332,25 +481,34 @@ export function streamSessionEvents(path: string): SessionEvent[] {
332
481
  }
333
482
  if (!Array.isArray(content)) continue;
334
483
  const textParts: string[] = [];
484
+ // Flush pending ADJACENT text before any tool block, so block order survives into the event
485
+ // stream (cross-family QE P1-1 — see the doc comment above).
486
+ const flushText = (): void => {
487
+ if (textParts.length === 0) return;
488
+ out.push({ kind: role === 'assistant' ? 'assistant' : 'user', text: textParts.join('\n') });
489
+ textParts.length = 0;
490
+ };
335
491
  for (const b of content) {
336
492
  if (!isObj(b)) continue; // guard a `[null]` block (cross-model QE)
337
493
  if (b.type === 'text' && typeof b.text === 'string') {
338
494
  textParts.push(b.text);
339
495
  } else if (b.type === 'tool_use') {
496
+ flushText();
340
497
  const input = isObj(b.input) ? (b.input as { file_path?: string; path?: string; command?: string }) : undefined;
341
498
  const file = input?.file_path ?? input?.path;
342
499
  const name = typeof b.name === 'string' ? b.name : undefined;
343
500
  // Capture the Bash COMMAND as the event text so a real verification (`pnpm tsc`, `npm test`) is
344
501
  // visible — dropping it made the "done without verify" check blind (cross-model QE).
345
502
  const text = (name === 'Bash' && typeof input?.command === 'string') ? input.command : (name ?? '');
346
- out.push({ kind: 'tool', text, ...(name ? { tool: name } : {}), ...(file ? { file } : {}) });
503
+ out.push({ kind: 'tool', text, ...(name ? { tool: name } : {}), ...(file ? { file } : {}), ...(typeof b.id === 'string' ? { toolUseId: b.id } : {}) });
347
504
  } else if (b.type === 'tool_result') {
505
+ flushText();
348
506
  const c = b.content;
349
507
  const text = typeof c === 'string' ? c : JSON.stringify(c ?? '');
350
- out.push({ kind: 'tool', text: text.slice(0, 2000), ok: b.is_error !== true });
508
+ out.push({ kind: 'tool', text: text.slice(0, 2000), ok: b.is_error !== true, ...(typeof b.tool_use_id === 'string' ? { toolUseId: b.tool_use_id } : {}) });
351
509
  }
352
510
  }
353
- if (textParts.length > 0) out.push({ kind: role === 'assistant' ? 'assistant' : 'user', text: textParts.join('\n') });
511
+ flushText();
354
512
  }
355
513
  return out;
356
514
  }
@@ -376,3 +534,300 @@ export function findLatestTranscript(repoRoot: string): string | null {
376
534
  } catch { /* ignore */ }
377
535
  return best === null ? null : (best as { path: string }).path;
378
536
  }
537
+
538
+ // ── Per-turn admission-debt scan (feature narrated-error-must-be-taught, ADR-001 D2/D4) ────────────
539
+ // The Stop hook runs `dz retro --scan-tail` after EVERY assistant turn, so this half is built around
540
+ // one budget: O(new bytes) — a persisted byte offset, no full re-read, no store open, no subprocess.
541
+ // The sentinel `.dz/retro-pending.json` is the debt; the recall hook turns it into a next-prompt
542
+ // directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4) clears it.
543
+
544
+ export const RETRO_SCAN_STATE_FILE = 'retro-scan-state.json';
545
+ export const RETRO_PENDING_FILE = 'retro-pending.json';
546
+ /** Bound the very FIRST scan of an already-huge transcript; later scans read only the new bytes. */
547
+ const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
548
+ /** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
549
+ const SENTINEL_FRESH_MS = 30 * 60 * 1000;
550
+
551
+ export interface RetroPendingSentinel {
552
+ readonly schema: 1;
553
+ readonly sessionId: string; // transcript basename without .jsonl
554
+ readonly transcript: string; // absolute transcript path — the debt belongs to THIS session
555
+ readonly snippet: string; // ≤200 chars around the admission marker
556
+ readonly ts: string; // ISO time of the scan that recorded the debt
557
+ }
558
+
559
+ /** Where the Stop-hook scan got its transcript path — or why it has none. */
560
+ export interface ScanTailSource {
561
+ /** The transcript to scan, or null when no source named one. */
562
+ readonly path: string | null;
563
+ readonly source: 'flag' | 'positional' | 'stop-hook-stdin' | 'none';
564
+ /** Present only when `path` is null: WHY the scan is refusing, in words a hook author can act on. */
565
+ readonly reason?: string;
566
+ }
567
+
568
+ /**
569
+ * Decide which transcript a `dz retro --scan-tail` run is entitled to read. PURE.
570
+ *
571
+ * Round 3, P1-3 (Codex r2 on `harness-cli/src/cli.ts:8508-8510`): the Stop-hook mode fell back to
572
+ * `findLatestTranscript(root)` whenever no positional path was given, so it scanned whichever file
573
+ * happened to have the newest mtime. With three to five sessions and their subagents alive at once —
574
+ * the ordinary state of this machine, not an edge case — that is routinely ANOTHER session's file:
575
+ * the scan advances the wrong session's offset and never sees the current turn's admission.
576
+ *
577
+ * The Stop hook hands the exact path on stdin (`{"session_id":…,"transcript_path":…}`), so the order
578
+ * is: an explicit `--transcript`, then a positional path (a human running it by hand means THAT file),
579
+ * then the hook payload. With none of the three the answer is a REFUSAL with a stated reason — never
580
+ * a guess. Refusing is safe here in a way guessing is not: a skipped scan self-heals on the next turn
581
+ * (nothing advanced), while a scan of the wrong transcript corrupts two sessions' state at once.
582
+ *
583
+ * A blank or whitespace-only string is NOT a path: an unset shell variable expands to exactly that.
584
+ */
585
+ export function resolveScanTailTranscript(input: {
586
+ readonly flag?: string | undefined;
587
+ readonly positional?: string | undefined;
588
+ readonly stdin?: string | undefined;
589
+ }): ScanTailSource {
590
+ const pick = (v: string | undefined): string | null => (typeof v === 'string' && v.trim() !== '' ? v : null);
591
+ const flag = pick(input.flag);
592
+ if (flag !== null) return { path: flag, source: 'flag' };
593
+ const positional = pick(input.positional);
594
+ if (positional !== null) return { path: positional, source: 'positional' };
595
+
596
+ const raw = typeof input.stdin === 'string' ? input.stdin.trim() : '';
597
+ if (raw !== '') {
598
+ let payload: unknown;
599
+ try { payload = JSON.parse(raw); } catch {
600
+ return {
601
+ path: null,
602
+ source: 'none',
603
+ reason: 'the Stop-hook payload on stdin could not be parsed as JSON, so it names no transcript_path — pass --transcript <path> explicitly',
604
+ };
605
+ }
606
+ const p = isObj(payload) ? (payload as { transcript_path?: unknown }).transcript_path : undefined;
607
+ const named = typeof p === 'string' ? pick(p) : null;
608
+ if (named !== null) return { path: named, source: 'stop-hook-stdin' };
609
+ }
610
+ return {
611
+ path: null,
612
+ source: 'none',
613
+ reason: 'no transcript_path on stdin and no --transcript/positional path — refusing to scan the newest transcript on disk, which on a machine running several sessions at once is routinely another session\'s',
614
+ };
615
+ }
616
+
617
+ export interface TailScanOutcome {
618
+ readonly status: 'pending' | 'cleared' | 'none' | 'no-transcript' | 'contended';
619
+ readonly snippet?: string;
620
+ readonly scannedBytes: number;
621
+ readonly offset: number;
622
+ }
623
+
624
+ /** The scan-state + sentinel pair is a read-modify-write store; per the repo concurrency rule
625
+ * (`.claude/rules/cross-runtime-concurrency.md`) it gets a named lock in the same change. */
626
+ export const RETRO_SCAN_LOCK_NAME = 'retro-scan';
627
+ /** A Stop hook must not queue behind a long holder — give up fast; the skipped scan self-heals
628
+ * (state not advanced ⇒ the next turn re-reads the same bytes). */
629
+ const RETRO_SCAN_LOCK_TIMEOUT_MS = 2_000;
630
+
631
+ /**
632
+ * Fold the admission debt over an event chunk. PURE. Asymmetric by design (ADR-001 D4): a new
633
+ * assistant admission ARMS the debt; only {@link isTeachCommand} — a Bash tool_use invoking teach
634
+ * at a command boundary — PAYS it. Neither prose ("I'll run dz teach"), nor an `echo`/`grep` decoy,
635
+ * nor a tool_result echoing the phrase settles anything (cross-family QE P1-2). Since the fix round's
636
+ * own review (finding R1) the command text is NECESSARY but not SUFFICIENT: a teach that carries a
637
+ * `tool_use_id` is only REGISTERED by its call, and the debt is settled by that call's own result
638
+ * carrying a teach RECEIPT ({@link TEACH_RECEIPT_RE}) — `exit 0\ndz teach "never runs"` pays nothing,
639
+ * and two parallel teaches that both come back receipt-less leave the debt armed (round 3, P1-2).
640
+ * A teach with NO id still pays on the call alone: nothing could ever confirm it. The detector in
641
+ * detectProcessRakes stays looser (any non-directive text mention) because its failure mode is a
642
+ * false accusation, while this fold's failure mode is a silently forgiven debt.
643
+ */
644
+ export function foldAdmissionDebt(
645
+ events: readonly SessionEvent[],
646
+ prior: { snippet: string } | null,
647
+ ): { snippet: string } | null {
648
+ let pending = prior;
649
+ // EVERY teach issued against the live debt and still awaiting its own result, by `tool_use_id`.
650
+ // Round 3, P1-2: a single `lastPaid` slot lost the FIRST of two parallel teaches — the second call
651
+ // saw an already-cleared `pending` and overwrote the slot with null, so when both results came back
652
+ // receipt-less neither could re-arm and the debt was silently forgiven. A set, and settlement moved
653
+ // to the RECEIPT, removes the whole class: the CALL now registers a candidate and changes nothing.
654
+ const awaiting = new Set<string>();
655
+ for (const e of events) {
656
+ if (isTeachCommand(e)) {
657
+ if (typeof e.toolUseId === 'string') {
658
+ // Registered, not settled. The debt stands until this call's own result carries a receipt.
659
+ if (pending !== null) awaiting.add(e.toolUseId);
660
+ } else {
661
+ // No pairing key ⇒ no result can ever confirm OR refute this call, so it pays on the command
662
+ // alone — the pre-ADR-004 behaviour, kept deliberately so every id-less fixture and every
663
+ // transcript written before ids reproduce their old outcome exactly.
664
+ pending = null;
665
+ awaiting.clear();
666
+ }
667
+ continue;
668
+ }
669
+ // The RESULT of one of those calls (a tool_result carries no tool name in this stream, by
670
+ // construction). A receipt on an ok result settles the debt the call was issued against; an
671
+ // is_error result, or an exit-0 run whose teach line was never reached, settles nothing and the
672
+ // candidate is simply dropped — the debt stays armed for the other candidates and for the scan.
673
+ if (e.kind === 'tool' && e.tool === undefined && typeof e.toolUseId === 'string' && awaiting.has(e.toolUseId)) {
674
+ awaiting.delete(e.toolUseId);
675
+ if (e.ok !== false && TEACH_RECEIPT_RE.test(e.text)) { pending = null; awaiting.clear(); }
676
+ continue;
677
+ }
678
+ if (e.kind === 'assistant') {
679
+ const snippet = admissionSnippet(e.text);
680
+ // A NEW admission supersedes every teach still in flight: those calls were issued against the
681
+ // OLDER debt, so their receipts must not settle this one (ADR-001 D4 asymmetry).
682
+ if (snippet !== null) { pending = { snippet }; awaiting.clear(); }
683
+ }
684
+ }
685
+ return pending;
686
+ }
687
+
688
+ const writeJsonAtomic = (path: string, value: unknown): void => {
689
+ const tmp = `${path}.${process.pid}.tmp`;
690
+ writeFileSync(tmp, JSON.stringify(value));
691
+ renameSync(tmp, path);
692
+ };
693
+
694
+ /**
695
+ * One incremental scan transaction: read state → read the new transcript bytes (whole lines only —
696
+ * a partial trailing line is left for the next scan) → fold the debt → persist state + sentinel.
697
+ * NEVER throws (it runs inside a Stop hook; a broken scan must never surface as a turn failure).
698
+ *
699
+ * The WHOLE read→fold→write runs under `withNamedLockSync` (cross-family QE P1-3): two overlapping
700
+ * Stop scans that both read the same offset/sentinel and then rename/unlink independently lose one
701
+ * side's update — atomic per-file renames do not prevent that, only mutual exclusion does. The
702
+ * critical section is short and synchronous (bounded ≤8 MB read, no subprocess, 65 ms measured on
703
+ * a 5 MB first scan) and the lock lives beside the store it guards: `<root>/.dz/locks/retro-scan.lock`
704
+ * for the store files in `<root>/.dz/`. A contended scan gives up fast and reports `contended` —
705
+ * nothing advanced, so the next turn re-scans the same bytes (self-healing, never a lost update).
706
+ */
707
+ export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
708
+ if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
709
+ try {
710
+ return withNamedLockSync(
711
+ dirname(dzDir),
712
+ RETRO_SCAN_LOCK_NAME,
713
+ () => scanTailUnderLock(dzDir, transcriptPath, nowIso),
714
+ { timeoutMs: RETRO_SCAN_LOCK_TIMEOUT_MS },
715
+ );
716
+ } catch (e) {
717
+ if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0 };
718
+ // Compromised lock or any unexpected failure: report nothing, advance nothing (never-block).
719
+ return { status: 'none', scannedBytes: 0, offset: 0 };
720
+ }
721
+ }
722
+
723
+ /** The transaction body — call ONLY under the named lock. Never throws for ordinary fs failures. */
724
+ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: string): TailScanOutcome {
725
+ try {
726
+ const statePath = join(dzDir, RETRO_SCAN_STATE_FILE);
727
+ const pendingPath = join(dzDir, RETRO_PENDING_FILE);
728
+
729
+ let offset = 0;
730
+ try {
731
+ const st = JSON.parse(readFileSync(statePath, 'utf8')) as { transcript?: string; offset?: number };
732
+ if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) offset = Math.floor(st.offset);
733
+ } catch { /* first scan of this transcript */ }
734
+
735
+ // Prior debt carries over ONLY for the same session; a stale sentinel (another session's debt)
736
+ // is dropped — the PreCompact/SessionEnd retro of THAT session was its collector, and injecting
737
+ // an old session's debt into a new one is noise (acid A9).
738
+ let prior: { snippet: string } | null = null;
739
+ let hadSentinel = false;
740
+ try {
741
+ const s = JSON.parse(readFileSync(pendingPath, 'utf8')) as Partial<RetroPendingSentinel>;
742
+ if (s.transcript === transcriptPath && typeof s.snippet === 'string') { prior = { snippet: s.snippet }; hadSentinel = true; }
743
+ else { try { unlinkSync(pendingPath); } catch { /* already gone */ } }
744
+ } catch { /* no sentinel */ }
745
+
746
+ let size = 0;
747
+ try { size = statSync(transcriptPath).size; } catch {
748
+ return prior !== null
749
+ ? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset }
750
+ : { status: 'none', scannedBytes: 0, offset };
751
+ }
752
+ if (size < offset) offset = 0; // truncated/rotated transcript
753
+ let jumped = offset === 0 && size > MAX_TAIL_SCAN_BYTES; // bound the first scan of a huge file
754
+ if (size - offset > MAX_TAIL_SCAN_BYTES) { offset = size - MAX_TAIL_SCAN_BYTES; jumped = true; }
755
+
756
+ let consumed = 0;
757
+ let events: SessionEvent[] = [];
758
+ if (size > offset) {
759
+ const fd = openSync(transcriptPath, 'r');
760
+ try {
761
+ const want = size - offset;
762
+ const buf = Buffer.allocUnsafe(want);
763
+ const got = readSync(fd, buf, 0, want, offset);
764
+ let lastNl = -1;
765
+ for (let i = got - 1; i >= 0; i--) if (buf[i] === 0x0a) { lastNl = i; break; }
766
+ if (lastNl >= 0) {
767
+ consumed = lastNl + 1;
768
+ let chunk = buf.toString('utf8', 0, consumed);
769
+ if (jumped) { // landed mid-line: drop the partial head
770
+ const nl = chunk.indexOf('\n');
771
+ chunk = nl >= 0 ? chunk.slice(nl + 1) : '';
772
+ }
773
+ events = parseSessionJsonl(chunk);
774
+ }
775
+ } finally { closeSync(fd); }
776
+ }
777
+
778
+ const next = foldAdmissionDebt(events, prior);
779
+ const newOffset = offset + consumed;
780
+ mkdirSync(dzDir, { recursive: true });
781
+ writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
782
+ if (next !== null) {
783
+ const sentinel: RetroPendingSentinel = {
784
+ schema: 1,
785
+ sessionId: basename(transcriptPath).replace(/\.jsonl$/, ''),
786
+ transcript: transcriptPath,
787
+ snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
788
+ ts: nowIso ?? new Date().toISOString(),
789
+ };
790
+ writeJsonAtomic(pendingPath, sentinel);
791
+ return { status: 'pending', snippet: sentinel.snippet, scannedBytes: consumed, offset: newOffset };
792
+ }
793
+ if (hadSentinel) {
794
+ try { unlinkSync(pendingPath); } catch { /* already gone */ }
795
+ return { status: 'cleared', scannedBytes: consumed, offset: newOffset };
796
+ }
797
+ return { status: 'none', scannedBytes: consumed, offset: newOffset };
798
+ } catch {
799
+ return { status: 'none', scannedBytes: 0, offset: 0 };
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Is this sentinel about the CURRENT session? Prefer identity (session id, then transcript path);
805
+ * only when the hook payload carries neither does the ts-freshness window decide. PURE.
806
+ */
807
+ /** TEST-ONLY handle on the UNLOCKED transaction body (ADR-002 D-4 / ADR-003 clause 3 RED half):
808
+ * the lost-update reproducer must be able to run the same read→fold→write WITHOUT mutual
809
+ * exclusion, so the named lock is proven to be what prevents the regression. Never call this from
810
+ * production code — `runRetroTailScan` is the only sanctioned entry point. */
811
+ export const __scanTailUnderLockForTest = scanTailUnderLock;
812
+
813
+ export function retroSentinelIsFresh(
814
+ sentinel: Partial<RetroPendingSentinel>,
815
+ ctx: { sessionId?: string; transcriptPath?: string; nowMs: number },
816
+ ): boolean {
817
+ if (typeof sentinel.snippet !== 'string' || sentinel.snippet === '') return false;
818
+ if (typeof ctx.sessionId === 'string' && ctx.sessionId !== '') return sentinel.sessionId === ctx.sessionId;
819
+ if (typeof ctx.transcriptPath === 'string' && ctx.transcriptPath !== '') return sentinel.transcript === ctx.transcriptPath;
820
+ const ts = typeof sentinel.ts === 'string' ? Date.parse(sentinel.ts) : NaN;
821
+ return Number.isFinite(ts) && ctx.nowMs - ts >= 0 && ctx.nowMs - ts < SENTINEL_FRESH_MS;
822
+ }
823
+
824
+ /**
825
+ * The ≤300-char next-prompt directive. It deliberately does NOT spell a teach invocation the scanner
826
+ * could mistake for the payment: events carrying RETRO_DEBT_MARKER are excluded from matching, and
827
+ * the phrasing keeps `dz` and `teach` apart as a second guard (ADR-001 D4). It demands the SPECIFIC
828
+ * lesson from the assistant — the hook itself only ever auto-teaches the templated one (D3).
829
+ */
830
+ export function renderRetroDebtDirective(sentinel: { snippet: string }): string {
831
+ const snip = sentinel.snippet.replace(/\s+/g, ' ').trim().slice(0, 70);
832
+ return `${RETRO_DEBT_MARKER}: last turn admitted an error — «${snip}» — with no recorded lesson. FIRST, before other work: run the dz \`teach\` command with the lesson + reproducer, then verify via dz recall. Narrating and recording are ONE action.`;
833
+ }