@dzhechkov/harness-core 0.7.4 → 0.7.5

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 (67) hide show
  1. package/.dz-manifest.json +111 -51
  2. package/README.md +1 -1
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +26 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +6 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/cadence.d.ts +66 -0
  10. package/dist/cadence.d.ts.map +1 -0
  11. package/dist/cadence.js +222 -0
  12. package/dist/cadence.js.map +1 -0
  13. package/dist/feature-adr-routing.d.ts.map +1 -1
  14. package/dist/feature-adr-routing.js +1 -1
  15. package/dist/feature-adr-routing.js.map +1 -1
  16. package/dist/index.d.ts +7 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/loop-blobs.generated.js +2 -2
  21. package/dist/loop-blobs.generated.js.map +1 -1
  22. package/dist/operations.d.ts.map +1 -1
  23. package/dist/operations.js +53 -18
  24. package/dist/operations.js.map +1 -1
  25. package/dist/publish.d.ts +31 -0
  26. package/dist/publish.d.ts.map +1 -1
  27. package/dist/publish.js +78 -0
  28. package/dist/publish.js.map +1 -1
  29. package/dist/recall-hook-policy.d.ts +3 -1
  30. package/dist/recall-hook-policy.d.ts.map +1 -1
  31. package/dist/recall-hook-policy.js +15 -2
  32. package/dist/recall-hook-policy.js.map +1 -1
  33. package/dist/recall-usage.d.ts +15 -0
  34. package/dist/recall-usage.d.ts.map +1 -1
  35. package/dist/recall-usage.js +51 -1
  36. package/dist/recall-usage.js.map +1 -1
  37. package/dist/score.d.ts.map +1 -1
  38. package/dist/score.js +38 -4
  39. package/dist/score.js.map +1 -1
  40. package/dist/tg-post.d.ts +54 -0
  41. package/dist/tg-post.d.ts.map +1 -0
  42. package/dist/tg-post.js +117 -0
  43. package/dist/tg-post.js.map +1 -0
  44. package/dist/usage.d.ts +31 -0
  45. package/dist/usage.d.ts.map +1 -1
  46. package/dist/usage.js +108 -21
  47. package/dist/usage.js.map +1 -1
  48. package/dist/writer-quiescence.d.ts +42 -0
  49. package/dist/writer-quiescence.d.ts.map +1 -0
  50. package/dist/writer-quiescence.js +82 -0
  51. package/dist/writer-quiescence.js.map +1 -0
  52. package/package.json +13 -13
  53. package/sbom.json +200 -50
  54. package/src/agentdb-index.ts +26 -2
  55. package/src/amendment-trace.ts +6 -1
  56. package/src/cadence.ts +227 -0
  57. package/src/feature-adr-routing.ts +1 -1
  58. package/src/index.ts +7 -1
  59. package/src/loop-blobs.generated.ts +2 -2
  60. package/src/operations.ts +52 -19
  61. package/src/publish.ts +98 -0
  62. package/src/recall-hook-policy.ts +15 -2
  63. package/src/recall-usage.ts +44 -1
  64. package/src/score.ts +30 -4
  65. package/src/tg-post.ts +137 -0
  66. package/src/usage.ts +132 -17
  67. package/src/writer-quiescence.ts +95 -0
@@ -18,7 +18,7 @@
18
18
  // ReferenceError into `appendRecallUsage`'s own catch and returned **0 rows appended, silently**.
19
19
  // The Codex recall leg looked wired and correctly-silent for exactly the reason AM-4's forced-hit
20
20
  // canary exists to expose. Importing node: modules at the top costs nothing — they are built in.
21
- import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from 'node:fs';
21
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync } from 'node:fs';
22
22
  import { dirname, join } from 'node:path';
23
23
 
24
24
  import {
@@ -718,3 +718,46 @@ function readLogTailSync(file: string): LogTail {
718
718
  return EMPTY_LOG_TAIL;
719
719
  }
720
720
  }
721
+
722
+ /**
723
+ * Recall events recorded for one run key — DISTINCT prompts, not rows.
724
+ *
725
+ * Why this exists: the /feature-adr live panel asserted `--recalled 3` as a LITERAL at three call
726
+ * sites, because the fallback writer that lights the panel had nowhere to get a real number. Now
727
+ * that `dz recall` records its own reads, the number is derivable — this is the derivation.
728
+ *
729
+ * Counted by `eventId`, not by row: one prompt that surfaced four lessons writes four rows sharing
730
+ * one eventId, and "recalled 4" would overstate what the operator did by a factor of hits-per-query.
731
+ * Rows predating eventIds (the log's schema grew) count one each — their rows WERE one-per-event.
732
+ *
733
+ * Returns null when the log cannot be read — the caller must record "unknown", never zero: an
734
+ * unreadable log and a run that recalled nothing are different facts (the dz sync 0/0 class).
735
+ */
736
+ export function countRecallEventsForRun(projectRoot: string, runId: string): number | null {
737
+ const wanted = runId.trim();
738
+ if (wanted === '') return null;
739
+ const path = join(projectRoot, ...RECALL_USAGE_LOG_RELATIVE.split('/'));
740
+ if (!existsSync(path)) return 0; // a real, readable absence: nothing has ever been recorded
741
+ let text: string;
742
+ try {
743
+ text = readFileSync(path, 'utf-8');
744
+ } catch {
745
+ return null;
746
+ }
747
+ const events = new Set<string>();
748
+ let preEventRows = 0;
749
+ for (const line of text.split('\n')) {
750
+ if (line.trim() === '') continue;
751
+ let row: Record<string, unknown>;
752
+ try {
753
+ row = JSON.parse(line) as Record<string, unknown>;
754
+ } catch {
755
+ continue; // a torn row is the chain verifier's business, not a reason to fail the count
756
+ }
757
+ if (row['runId'] !== wanted) continue;
758
+ const ev = row['eventId'];
759
+ if (typeof ev === 'string' && ev !== '') events.add(ev);
760
+ else preEventRows += 1;
761
+ }
762
+ return events.size + preEventRows;
763
+ }
package/src/score.ts CHANGED
@@ -76,7 +76,12 @@ const NEGATION_RE = /\b(no|not|never|without|wasn'?t|isn'?t)\b/i;
76
76
  * `\bno\b` does NOT match "Nothing". Hedges like "skipped" stay out of both lists — they routinely
77
77
  * appear inside genuine evidence lines.
78
78
  */
79
- const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b/i;
79
+ // RU negation quantifiers joined 2026-08-24 with the RU live-markers (773185ca): a corpus where
80
+ // 63% of traffic is Russian was screened by an English-only list — «ничего не измерено» would have
81
+ // read as a live marker the moment ИЗМЕРЕНО joined the positives.
82
+ // \b is ASCII-only in JS even under /u — «не» never matched through it (measured by the pin the
83
+ // moment it was written). Unicode lookarounds carry the boundary instead.
84
+ const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b|(?<![\p{L}\p{N}])(не|нет|ни одного|ничего|никогда|без)(?![\p{L}\p{N}])/iu;
80
85
 
81
86
  function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEGATION_RE): string | null {
82
87
  for (const line of text.split('\n')) {
@@ -137,7 +142,25 @@ export interface GradeReading {
137
142
  */
138
143
  export function readQeGrade(qeText: string): GradeReading {
139
144
  const found: string[] = [];
145
+ const lines = qeText.split('\n');
146
+ // Line offsets once, so each match maps to ITS line for the negation screen (67d7883d: the
147
+ // parser was negation-blind — «No Grade: B was assigned» contributed B and a cross-model PASS;
148
+ // the display-locator fix could not reach this because the GRADE rests here). The same
149
+ // deliberate trade as evidenceLinePositive: a genuine grade on a line that happens to carry a
150
+ // negation is SKIPPED (visible in `found`'s absence) rather than a negated line being COUNTED.
151
+ const lineStarts: number[] = [0];
152
+ for (let i = 0; i < lines.length - 1; i++) lineStarts.push((lineStarts[i] as number) + (lines[i] as string).length + 1);
153
+ // The screen covers the line PREFIX up to the match, not the whole line: «No Grade: B was
154
+ // assigned» negates BEFORE the grade; «**Grade: B** — no blockers remain» carries its negation
155
+ // AFTER, about something else entirely, and whole-line screening dropped that real, common
156
+ // phrase (caught by the standing display-locator pin the moment the sweep was «completed»).
157
+ const linePrefixOf = (idx: number): string => {
158
+ let lo = 0, hi = lineStarts.length - 1;
159
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if ((lineStarts[mid] as number) <= idx) lo = mid; else hi = mid - 1; }
160
+ return qeText.slice(lineStarts[lo] as number, idx);
161
+ };
140
162
  for (const m of qeText.matchAll(new RegExp(GRADE_RE.source, 'g'))) {
163
+ if (NEGATION_QUANTIFIED_RE.test(linePrefixOf(m.index ?? 0))) continue;
141
164
  const g = normaliseGradeSign(m[1] as string);
142
165
  if (!found.includes(g)) found.push(g);
143
166
  }
@@ -226,14 +249,17 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
226
249
  // report is the cautionary case: "✅ (mechanism)" with no live evidence shipped a dead feature.
227
250
  // POSITIVE (wave1-scorer-negation): "nothing was MEASURED" / "no reproducer was run" is the
228
251
  // claim's exact opposite and used to score as proof of it (the crossrt-1 6/7 shape).
229
- const live = evidenceLinePositive(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
252
+ // 773185ca: MEASURED-class markers in BOTH working languages. dz-recap carried 10× ИЗМЕРЕНО and
253
+ // 0× MEASURED and scored «всё выведено рассуждением» — the cyrillic-tokenizer class again.
254
+ const LIVE_MARKER_RE = /MEASURED|verified live|VERIFIED LIVE|reproducer|ИЗМЕРЕНО|МЕРЕНО|измерено|проверено живьём|живой прогон|репродьюсер/iu;
255
+ const live = evidenceLinePositive(qeText, LIVE_MARKER_RE, NEGATION_QUANTIFIED_RE);
230
256
  const liveAnywhere =
231
- live ?? evidenceLinePositive(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
257
+ live ?? evidenceLinePositive(allText, LIVE_MARKER_RE, NEGATION_QUANTIFIED_RE);
232
258
  add(
233
259
  'live-verification',
234
260
  'claims verified by running, not by reasoning',
235
261
  live !== null ? 'pass' : liveAnywhere !== null ? 'partial' : 'absent',
236
- live ?? liveAnywhere ?? 'no MEASURED / verified-live / reproducer marker anywhere — every claim is inferred',
262
+ live ?? liveAnywhere ?? 'no MEASURED/ИЗМЕРЕНО/verified-live/reproducer marker anywhere — every claim is inferred',
237
263
  );
238
264
 
239
265
  // 5. README-first — the docs travelled in the same change.
package/src/tg-post.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `dz tg-post` — the pure half: validate an approved draft against what Telegram will accept and
3
+ * what the channel's own accepted design demands.
4
+ *
5
+ * The design is NOT this module's to invent. features/genai-tweets-channel/ carries four ACCEPTED
6
+ * ADRs (2026-08-04): HTML mode, never MarkdownV2 (18 escapes against 3, one miss is a 400); the
7
+ * cadence rule "no posts 00:00-06:00 MSK"; link previews off by default because x.com previews in
8
+ * Telegram have been broken since 2022; and ADR-004's standing order — publishing stays MANUAL, and
9
+ * autonomous publishing means REVISING the ADR, not flipping a quiet flag. This module only makes
10
+ * those decisions checkable.
11
+ *
12
+ * PURE: no filesystem, no network, no clock — the timestamp arrives as a parameter, or the MSK
13
+ * night-window rule could not be tested at all.
14
+ */
15
+
16
+ /** Tags Bot API accepts in HTML mode (verified against the live docs, Bot API 10.2). */
17
+ export const TG_ALLOWED_TAGS: readonly string[] = [
18
+ 'b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'tg-spoiler',
19
+ 'a', 'tg-emoji', 'code', 'pre', 'blockquote',
20
+ ];
21
+
22
+ /** Hard ceiling for `sendMessage.text`, characters after entity parsing. */
23
+ export const TG_TEXT_LIMIT = 4096;
24
+
25
+ export interface TgHtmlIssue {
26
+ readonly kind: 'unknown-tag' | 'unclosed-tag' | 'stray-close' | 'bare-ampersand' | 'bare-angle' | 'over-limit' | 'empty';
27
+ readonly detail: string;
28
+ }
29
+
30
+ /**
31
+ * The character count Telegram limits: text WITHOUT the markup. An approximation is stated as one —
32
+ * entities like tg-emoji count differently — but a draft within this bound by a margin is safe, and
33
+ * the render prints the number so the author sees the headroom, not a verdict.
34
+ */
35
+ export function tgVisibleLength(html: string): number {
36
+ return html.replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').length;
37
+ }
38
+
39
+ /**
40
+ * Everything wrong with a draft, or an empty list. One pass, every finding named — a validator that
41
+ * stops at the first fault sends the author around the loop once per mistake.
42
+ */
43
+ export function tgPostHtmlIssues(html: string): TgHtmlIssue[] {
44
+ const issues: TgHtmlIssue[] = [];
45
+ const text = html.trim();
46
+ if (text === '') return [{ kind: 'empty', detail: 'the draft is empty — nothing to send' }];
47
+
48
+ // Tag balance over the allowed set. Telegram closes nothing for you: an unclosed <b> is a 400.
49
+ const stack: string[] = [];
50
+ const tagRe = /<(\/?)([a-zA-Z-]+)((?:\s+[a-zA-Z-]+(?:="[^"]*")?)*)\s*(\/?)>/g;
51
+ let covered = 0;
52
+ for (let m = tagRe.exec(text); m !== null; m = tagRe.exec(text)) {
53
+ covered += 1;
54
+ const closing = m[1] === '/';
55
+ const name = (m[2] as string).toLowerCase();
56
+ const expandable = name === 'blockquote'; // `<blockquote expandable>` is the ADR-003 body form
57
+ if (!TG_ALLOWED_TAGS.includes(name) && !expandable) {
58
+ issues.push({ kind: 'unknown-tag', detail: `<${name}> is not a Bot API HTML tag — Telegram answers 400 to tags it does not know` });
59
+ continue;
60
+ }
61
+ if (closing) {
62
+ if (stack.length === 0 || stack[stack.length - 1] !== name) {
63
+ issues.push({ kind: 'stray-close', detail: `</${name}> closes nothing that is open — tags must nest, not interleave` });
64
+ } else {
65
+ stack.pop();
66
+ }
67
+ } else if (m[4] !== '/') {
68
+ stack.push(name);
69
+ }
70
+ }
71
+ for (const open of stack) {
72
+ issues.push({ kind: 'unclosed-tag', detail: `<${open}> is never closed — Telegram closes nothing for you, this is a 400` });
73
+ }
74
+
75
+ // Bare & and < outside tags: HTML mode requires entity-escaping exactly these.
76
+ const outside = text.replace(/<[^>]*>/g, '');
77
+ if (/&(?!(lt|gt|amp|quot|#\d+|#x[0-9a-fA-F]+);)/.test(outside)) {
78
+ issues.push({ kind: 'bare-ampersand', detail: 'a bare & outside an entity — HTML mode needs &amp;' });
79
+ }
80
+ if (/</.test(outside.replace(/&lt;/g, ''))) {
81
+ issues.push({ kind: 'bare-angle', detail: 'a bare < that is not a known tag — Telegram reads it as markup and answers 400' });
82
+ }
83
+
84
+ const visible = tgVisibleLength(text);
85
+ if (visible > TG_TEXT_LIMIT) {
86
+ issues.push({ kind: 'over-limit', detail: `${visible} visible characters against the ${TG_TEXT_LIMIT} hard limit — cut ${visible - TG_TEXT_LIMIT}` });
87
+ }
88
+ void covered;
89
+ return issues;
90
+ }
91
+
92
+ export interface TgSendDecision {
93
+ readonly action: 'send' | 'refuse';
94
+ readonly reason: string;
95
+ }
96
+
97
+ /**
98
+ * May this draft go out NOW?
99
+ *
100
+ * The night window is ADR-003's cadence rule, encoded as a refusal with an explicit override rather
101
+ * than as advice: 00:00-06:00 MSK is when the channel's audience is asleep and its author is too —
102
+ * a send landing then is far more often a timezone mistake than an intention. `--night` states the
103
+ * intention; without it the refusal names the local MSK time it computed, so the operator can check
104
+ * the arithmetic instead of trusting it.
105
+ */
106
+ export function decideTgSend(input: {
107
+ readonly issues: readonly TgHtmlIssue[];
108
+ readonly provenanceOutcome: 'allowed' | 'blocked' | 'not-established' | 'skipped';
109
+ readonly confirmed: boolean;
110
+ readonly nowUtcIso: string;
111
+ readonly nightOverride: boolean;
112
+ }): TgSendDecision {
113
+ if (input.issues.length > 0) {
114
+ return { action: 'refuse', reason: `${input.issues.length} formatting issue(s) — Telegram would refuse or mangle this draft` };
115
+ }
116
+ // The provenance gate is not optional and "skipped" is not a pass: ADR-002 of the provenance
117
+ // feature — nothing leaves this machine citing a source that may not.
118
+ if (input.provenanceOutcome !== 'allowed') {
119
+ return {
120
+ action: 'refuse',
121
+ reason: input.provenanceOutcome === 'skipped'
122
+ ? 'no provenance manifest was checked — an unchecked draft is not an approved draft'
123
+ : `the provenance gate said ${input.provenanceOutcome} — nothing goes out citing a source that may not leave this machine`,
124
+ };
125
+ }
126
+ if (!input.confirmed) {
127
+ return { action: 'refuse', reason: 'publishing is MANUAL by the channel\'s own ADR-004 — pass --send --yes to state the decision out loud' };
128
+ }
129
+ const utc = Date.parse(input.nowUtcIso);
130
+ if (Number.isFinite(utc)) {
131
+ const mskHour = new Date(utc + 3 * 3600_000).getUTCHours();
132
+ if (mskHour < 6 && !input.nightOverride) {
133
+ return { action: 'refuse', reason: `it is ${String(mskHour).padStart(2, '0')}:xx MSK — the channel posts nothing between 00:00 and 06:00 MSK (ADR-003). Pass --night if this is deliberate` };
134
+ }
135
+ }
136
+ return { action: 'send', reason: 'formatted, provenance-cleared, confirmed, and inside posting hours' };
137
+ }
package/src/usage.ts CHANGED
@@ -146,6 +146,11 @@ export interface UsageLimits {
146
146
  readonly sessionBlockHours?: number;
147
147
  readonly calibratedAt?: string;
148
148
  readonly source?: string;
149
+ /** Routing must not read the estimated pcts (ADR-001 usage-honesty, FR-3). Legacy
150
+ * `_disabledReason` free-text also reads as true — the note WAS the switch, now it is data. */
151
+ readonly routingDisabled?: boolean;
152
+ /** Account identity captured at calibration time (FR-4); a login change stales the calibration. */
153
+ readonly calibrationAccount?: string | null;
149
154
  }
150
155
 
151
156
  export interface UsageModelEstimate {
@@ -178,6 +183,21 @@ export interface UsageEstimate {
178
183
  /** Traceability for tests and calibration diagnostics. */
179
184
  readonly sessionStartedAt: string | null;
180
185
  readonly weeklyStartedAt: string | null;
186
+ /**
187
+ * Why the routed pcts are null (ADR-001 usage-honesty): empty ⇔ the numbers are established.
188
+ * `scan-empty` — recent transcripts exist yet the scan extracted nothing (instrument failure);
189
+ * `window-miss:*` — samples exist but the configured window filtered them all (misaligned
190
+ * anchor); `routing-disabled` — config says the estimates must not steer; `calibration-stale` —
191
+ * the account changed since calibration. A zero pct is legitimate ONLY on a machine with no
192
+ * recent transcripts at all.
193
+ */
194
+ readonly notEstablished: readonly UsageNotEstablishedReason[];
195
+ /**
196
+ * The raw estimates when the routed pcts are nulled by `routing-disabled`/`calibration-stale` —
197
+ * for HUMAN eyes (recalled lesson: an estimated pct must never drive routing until its window is
198
+ * verified against the provider). Absent when the top-level pcts already carry the numbers.
199
+ */
200
+ readonly estimatesNotForRouting?: { readonly sessionPct: number | null; readonly weeklyPct: number | null };
181
201
  }
182
202
 
183
203
  export interface UsageCalibrationInput {
@@ -211,6 +231,8 @@ interface MutableUsageLimits {
211
231
  sessionBlockHours?: number;
212
232
  calibratedAt?: string;
213
233
  source?: string;
234
+ routingDisabled?: boolean;
235
+ calibrationAccount?: string | null;
214
236
  }
215
237
 
216
238
  const WEEKDAY_TO_DAY: Record<string, number> = {
@@ -337,10 +359,59 @@ export function normalizeClaudeUsageModelKey(raw: unknown): ClaudeUsageModel | n
337
359
  return (CLAUDE_USAGE_MODELS as readonly string[]).includes(key) ? (key as ClaudeUsageModel) : null;
338
360
  }
339
361
 
362
+ /** Recursive .jsonl collector under a subagents tree — bounded depth, lstat-guarded. */
363
+ function walkTranscriptTree(dir: string, depthLeft: number, out: Array<{ path: string; mtimeMs: number }>): void {
364
+ if (depthLeft <= 0) return;
365
+ let entries: string[];
366
+ try {
367
+ if (!lstatSync(dir).isDirectory()) return; // symlinked dir ⇒ not walked
368
+ entries = readdirSync(dir);
369
+ } catch {
370
+ return;
371
+ }
372
+ for (const e of entries) {
373
+ const p = join(dir, e);
374
+ if (e.endsWith('.jsonl')) {
375
+ const m = regularFileMtime(p);
376
+ if (m !== null) out.push({ path: p, mtimeMs: m });
377
+ } else {
378
+ walkTranscriptTree(p, depthLeft - 1, out);
379
+ }
380
+ }
381
+ }
382
+
340
383
  /**
341
384
  * The `~/.claude/projects` root (the account-wide transcript store). Overridable via
342
385
  * `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree. Never throws.
343
386
  */
387
+ /**
388
+ * The logged-in account identity, from `~/.claude.json` (oauthAccount email or uuid). Honest null
389
+ * when unreadable/absent — and null==null is NOT an account change (machines that never expose it
390
+ * keep the pre-FR-4 behavior). Never throws.
391
+ */
392
+ export function readClaudeAccountId(): string | null {
393
+ try {
394
+ const raw = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf-8')) as {
395
+ oauthAccount?: { emailAddress?: unknown; accountUuid?: unknown };
396
+ };
397
+ const email = raw.oauthAccount?.emailAddress;
398
+ if (typeof email === 'string' && email !== '') return email;
399
+ const uuid = raw.oauthAccount?.accountUuid;
400
+ if (typeof uuid === 'string' && uuid !== '') return uuid;
401
+ return null;
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ /** Closed set of not-established reasons (ADR-001). */
408
+ export type UsageNotEstablishedReason =
409
+ | 'scan-empty'
410
+ | 'window-miss:weekly'
411
+ | 'routing-disabled'
412
+ | 'calibration-stale:account-changed'
413
+ | 'calibration-stale:account-unverifiable';
414
+
344
415
  export function claudeProjectsRoot(): string {
345
416
  const override = process.env['DZ_CLAUDE_PROJECTS_ROOT'];
346
417
  if (typeof override === 'string' && override.length > 0) return override;
@@ -387,6 +458,12 @@ export function readUsageLimits(projectRoot: string): UsageLimits {
387
458
 
388
459
  if (typeof u['calibratedAt'] === 'string') out.calibratedAt = u['calibratedAt'];
389
460
  if (typeof u['source'] === 'string') out.source = u['source'];
461
+ // routingDisabled: the boolean is authoritative; the legacy free-text note counts as true so
462
+ // the fleet's existing config disables TODAY, without an edit.
463
+ if (u['routingDisabled'] === true || typeof u['_disabledReason'] === 'string') out.routingDisabled = true;
464
+ if (typeof u['calibrationAccount'] === 'string' || u['calibrationAccount'] === null) {
465
+ out.calibrationAccount = u['calibrationAccount'] as string | null;
466
+ }
390
467
  return out;
391
468
  } catch {
392
469
  return {};
@@ -440,22 +517,15 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
440
517
  continue;
441
518
  }
442
519
  for (const f of files) {
443
- // A session's SUBAGENT transcripts live one level deeper — `<session>/subagents/*.jsonl` and
444
- // carry real, non-duplicated usage that was silently excluded (MEASURED: 27 such files on this
445
- // machine). Their tokens are spent exactly like the main loop's.
520
+ // A session's SUBAGENT transcripts live under `<session>/subagents/` and carry real,
521
+ // non-duplicated usage that was silently excluded (MEASURED: 27 such files in the first
522
+ // round). The walk is RECURSIVE with a depth cap: workflow agents write to
523
+ // `subagents/workflows/wf_*/agent-*.jsonl` — one level deeper than the first fix reached —
524
+ // and that blind spot alone hid 283.62M weighted tokens across 551 files (MEASURED
525
+ // 2026-08-24, 7-day window, this machine). Depth 4 covers today's deepest layout plus one
526
+ // future level; lstat at EVERY step keeps symlinked directories unwalked.
446
527
  if (!f.endsWith('.jsonl')) {
447
- const nested = join(projDir, f, 'subagents');
448
- try {
449
- if (!lstatSync(nested).isDirectory()) continue; // no symlinked session/subagent dirs
450
- for (const sf of readdirSync(nested)) {
451
- if (!sf.endsWith('.jsonl')) continue;
452
- const sp = join(nested, sf);
453
- const m = regularFileMtime(sp);
454
- if (m !== null) out.push({ path: sp, mtimeMs: m });
455
- }
456
- } catch {
457
- /* not a session dir — skip */
458
- }
528
+ walkTranscriptTree(join(projDir, f, 'subagents'), 4, out);
459
529
  continue;
460
530
  }
461
531
  const p = join(projDir, f);
@@ -618,11 +688,13 @@ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
618
688
 
619
689
  const samples: Sample[] = [];
620
690
  const seen = new Set<string>();
691
+ let scanFileCount = 0;
621
692
  try {
622
693
  const files = listTranscriptFiles(claudeProjectsRoot());
623
694
  for (const f of files) {
624
695
  // mtime prefilter: a file last written before every relevant cutoff cannot contribute.
625
696
  if (f.mtimeMs < scanCutoff) continue;
697
+ scanFileCount += 1;
626
698
  extractSamples(f.path, scanCutoff, samples, seen);
627
699
  }
628
700
  } catch {
@@ -660,11 +732,46 @@ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
660
732
  }
661
733
  }
662
734
 
735
+ // ── Establishment gate (ADR-001): a number may only flow to the routed pct fields when the
736
+ // scan actually established it. Fail-closed in exactly four named ways; the raw estimates stay
737
+ // visible to humans under estimatesNotForRouting when policy (not measurement) nulls them.
738
+ const reasons: UsageNotEstablishedReason[] = [];
739
+ const recentFiles = scanFileCount > 0;
740
+ if (recentFiles && samples.length === 0) reasons.push('scan-empty');
741
+ // An IDLE session inside a busy week (block 0, weekly > 0) is a MEASURED zero, not a miss — the
742
+ // first cut of this gate flagged it and four standing tests rightly reddened. Second narrowing
743
+ // (cross-family review): a week that JUST reset over an idle machine still scans pre-reset
744
+ // samples (the session cutoff reaches 10h back), and weekly 0 is then a healthy fresh week. The
745
+ // true miss signature needs a sample AT or PAST the window start that the window still refuses —
746
+ // future-stamped (clock skew) or beyond-reset (stale anchor) — exactly d3639bf0's shape.
747
+ if (
748
+ weeklyWindow !== null &&
749
+ weeklyTokens <= 0 &&
750
+ samples.some((smp) => smp.ts >= weeklyWindow.startedAtMs)
751
+ ) {
752
+ reasons.push('window-miss:weekly');
753
+ }
754
+ if (limits.routingDisabled === true) reasons.push('routing-disabled');
755
+ const account = readClaudeAccountId();
756
+ if (limits.calibrationAccount !== undefined && limits.calibrationAccount !== null) {
757
+ // A stored identity DEMANDS verification (cross-family review: null-current was fail-open —
758
+ // an unreadable ~/.claude.json silently reused another account's calibration). A stored null
759
+ // stays exempt: those machines never claimed an identity to verify.
760
+ if (account === null) reasons.push('calibration-stale:account-unverifiable');
761
+ else if (account !== limits.calibrationAccount) reasons.push('calibration-stale:account-changed');
762
+ }
763
+ const rawSessionPct = pct(block.tokens, limits.sessionTokenLimit);
764
+ const rawWeeklyPct = weeklyPct;
765
+ const measurementBroken = reasons.some((r) => r === 'scan-empty' || r.startsWith('window-miss'));
766
+ const policyNulled = reasons.some((r) => r === 'routing-disabled' || r.startsWith('calibration-stale'));
767
+ const gatedSessionPct = measurementBroken || policyNulled ? null : rawSessionPct;
768
+ const gatedWeeklyPct = measurementBroken || policyNulled ? null : rawWeeklyPct;
769
+
663
770
  return {
664
771
  sessionTokens: block.tokens,
665
772
  weeklyTokens,
666
- sessionPct: pct(block.tokens, limits.sessionTokenLimit),
667
- weeklyPct,
773
+ sessionPct: gatedSessionPct,
774
+ weeklyPct: gatedWeeklyPct,
668
775
  sessionResetsAt: block.resetsAtMs === null ? null : new Date(block.resetsAtMs).toISOString(),
669
776
  weeklyResetsAt: weeklyWindow === null ? null : new Date(weeklyWindow.resetsAtMs).toISOString(),
670
777
  estimated: true,
@@ -673,6 +780,10 @@ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
673
780
  ...(weeklyBindingModel !== undefined ? { weeklyBindingModel } : {}),
674
781
  sessionStartedAt: block.startedAtMs === null ? null : new Date(block.startedAtMs).toISOString(),
675
782
  weeklyStartedAt: weeklyWindow === null ? null : new Date(weeklyWindow.startedAtMs).toISOString(),
783
+ notEstablished: reasons,
784
+ ...(policyNulled && !measurementBroken
785
+ ? { estimatesNotForRouting: { sessionPct: rawSessionPct, weeklyPct: rawWeeklyPct } }
786
+ : {}),
676
787
  };
677
788
  }
678
789
 
@@ -782,6 +893,10 @@ export function deriveUsageCalibration(
782
893
  if (changes.length > 0) {
783
894
  after.calibratedAt = input.calibratedAt;
784
895
  after.source = input.source;
896
+ // FR-4: a calibration is a claim about ONE account's limits. Stamp whose — a later login under
897
+ // a different identity then stales it by itself (the 2026-08-24 re-login is the reproducer:
898
+ // the old anchor kept printing 55% on the new account).
899
+ after.calibrationAccount = readClaudeAccountId();
785
900
  }
786
901
 
787
902
  return { before, after, changes, skipped };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Writer-quiescence probe for Step 8 — feature qe-writer-quiescence (backlog 700b46a4).
3
+ *
4
+ * MEASURED (crossrt-1, 2026-08-18): Step-8 graded a MOVING tree — a background worker wrote at
5
+ * 19:25, 19:32 and 19:46, AFTER the verdict, its last write clobbering a file the same round had
6
+ * just written. The reviewer hand-waited six consecutive 30-second zero-write windows and
7
+ * re-measured everything. This module is that wait, as a machine: the same idea the publish gate
8
+ * already enforces («не публикуй, пока рой жив»), applied to grading.
9
+ *
10
+ * A BELT, not the root: mutual exclusion of writers (worktree isolation) is item 9520e506. The
11
+ * probe therefore NEVER blocks a run — a moving tree downgrades the verdict's standing loudly
12
+ * instead of stopping the pipeline.
13
+ *
14
+ * PURE: the shell script is GENERATED here and executed by a workflow agent (the workflow sandbox
15
+ * has no child_process — the agent is the shell, same as the landing barrier); the answer is
16
+ * PARSED here, parse-never-synthesize: an empty or malformed probe is 'inconclusive', never
17
+ * 'quiet'.
18
+ */
19
+
20
+ export interface WriterQuiescenceDecision {
21
+ readonly verdict: 'quiet' | 'moving' | 'inconclusive';
22
+ /** Per-window changed counts actually parsed, in order. */
23
+ readonly windows: readonly number[];
24
+ readonly note: string;
25
+ }
26
+
27
+ export const WQ_WINDOW_SECONDS = 20;
28
+ export const WQ_MAX_WINDOWS = 9;
29
+ export const WQ_REQUIRED_QUIET = 3;
30
+
31
+ /**
32
+ * The probe script a workflow agent runs verbatim. Polls the declared targets PLUS the feature
33
+ * dir (FR-4: the feature's own artifacts are exactly the surface crossrt-1 saw clobbered) in
34
+ * fixed windows; prints one `WQ-WINDOW <n> changed=<count>` line per window and exits early after
35
+ * `requiredQuiet` consecutive zeros. `find -newermt '-<w+5> seconds'` widens the lookback slightly
36
+ * past the sleep so a write on the window boundary cannot fall between two polls.
37
+ */
38
+ export function quiescenceProbeScript(
39
+ paths: readonly string[],
40
+ opts?: { windowSeconds?: number; maxWindows?: number; requiredQuiet?: number },
41
+ ): string {
42
+ const w = opts?.windowSeconds ?? WQ_WINDOW_SECONDS;
43
+ const max = opts?.maxWindows ?? WQ_MAX_WINDOWS;
44
+ const need = opts?.requiredQuiet ?? WQ_REQUIRED_QUIET;
45
+ const targets = paths
46
+ .map((p) => String(p).trim())
47
+ .filter((p) => p !== '' && !p.startsWith('-') && !p.includes("'"))
48
+ .map((p) => `'${p.replace(/'/g, '')}'`)
49
+ .join(' ');
50
+ const lookback = w + 5;
51
+ // Cross-family review (round 1, D): find errors — a missing/typo'd target, a permission failure —
52
+ // used to feed ZERO to `wc -l`, so a probe that could not look reported quiet. Errors now print
53
+ // `changed=ERR` and the parser refuses to let an ERR window feed a quiet streak.
54
+ return (
55
+ `quiet=0; n=0; while [ $n -lt ${max} ]; do n=$((n+1)); sleep ${w}; ` +
56
+ `out=$(find ${targets} -type f -newermt '-${lookback} seconds' 2>&1 >/tmp/wq-list.$$); st=$?; ` +
57
+ `if [ $st -ne 0 ] || [ -n "$out" ]; then c=ERR; else c=$(wc -l < /tmp/wq-list.$$); fi; rm -f /tmp/wq-list.$$; ` +
58
+ `echo "WQ-WINDOW $n changed=$c"; ` +
59
+ `if [ "$c" = "0" ]; then quiet=$((quiet+1)); if [ $quiet -ge ${need} ]; then echo "WQ-DONE quiet"; exit 0; fi; else quiet=0; fi; ` +
60
+ `done; echo "WQ-DONE budget"`
61
+ );
62
+ }
63
+
64
+ /** Parse the probe transcript into a verdict. Empty/malformed ⇒ inconclusive, never quiet. */
65
+ export function decideWriterQuiescence(probeText: unknown, requiredQuiet = WQ_REQUIRED_QUIET): WriterQuiescenceDecision {
66
+ const text = probeText === null || probeText === undefined ? '' : String(probeText);
67
+ // -1 encodes an ERR window (find could not look): it can never join a quiet streak, and its
68
+ // presence degrades a no-streak outcome to 'inconclusive' — an instrument that failed to observe
69
+ // must not testify to movement either.
70
+ const windows: number[] = [];
71
+ for (const line of text.split(/\r?\n/)) {
72
+ const m = /WQ-WINDOW\s+\d+\s+changed=(\d+|ERR)/.exec(line);
73
+ if (m) windows.push(m[1] === 'ERR' ? -1 : Number(m[1]));
74
+ }
75
+ if (windows.length === 0) {
76
+ return { verdict: 'inconclusive', windows, note: 'quiescence probe returned no windows — grading standing NOT established (probe failure is never quiet)' };
77
+ }
78
+ let streak = 0;
79
+ for (const c of windows) {
80
+ streak = c === 0 ? streak + 1 : 0;
81
+ if (streak >= requiredQuiet) {
82
+ // The claim is exactly what was measured (cross-family review): mtime evidence of no recent
83
+ // writes — never a writer-lifecycle guarantee. The root guarantee is worktree isolation.
84
+ return { verdict: 'quiet', windows, note: `no observed writes in ${requiredQuiet} consecutive windows (mtime evidence only — not a writer-lifecycle guarantee)` };
85
+ }
86
+ }
87
+ if (windows.some((c) => c < 0)) {
88
+ return { verdict: 'inconclusive', windows, note: 'quiescence probe could not observe every window (find errored) — grading standing NOT established' };
89
+ }
90
+ return {
91
+ verdict: 'moving',
92
+ windows,
93
+ note: `tree is MOVING: no ${requiredQuiet} consecutive quiet windows within budget (per-window changed counts: ${windows.join(',')}) — the verdict below was graded on a moving tree and must say so`,
94
+ };
95
+ }