@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.
- package/.dz-manifest.json +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
package/src/session-retro.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
313
|
-
* ("Done." + "Fixed.") counts as ONE claim, not two (
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|