@dzhechkov/harness-core 0.7.3 → 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.
- package/.dz-manifest.json +131 -51
- package/README.md +1 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +26 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +6 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/cadence.d.ts +66 -0
- package/dist/cadence.d.ts.map +1 -0
- package/dist/cadence.js +222 -0
- package/dist/cadence.js.map +1 -0
- package/dist/cli-flag-notice.d.ts +50 -0
- package/dist/cli-flag-notice.d.ts.map +1 -0
- package/dist/cli-flag-notice.js +106 -0
- package/dist/cli-flag-notice.js.map +1 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +1 -1
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +61 -19
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +31 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +78 -0
- package/dist/publish.js.map +1 -1
- package/dist/recall-hook-policy.d.ts +3 -1
- package/dist/recall-hook-policy.d.ts.map +1 -1
- package/dist/recall-hook-policy.js +15 -2
- package/dist/recall-hook-policy.js.map +1 -1
- package/dist/recall-usage.d.ts +15 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +51 -1
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +38 -4
- package/dist/score.js.map +1 -1
- package/dist/tg-post.d.ts +54 -0
- package/dist/tg-post.d.ts.map +1 -0
- package/dist/tg-post.js +117 -0
- package/dist/tg-post.js.map +1 -0
- package/dist/usage.d.ts +31 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +108 -21
- package/dist/usage.js.map +1 -1
- package/dist/writer-quiescence.d.ts +42 -0
- package/dist/writer-quiescence.d.ts.map +1 -0
- package/dist/writer-quiescence.js +82 -0
- package/dist/writer-quiescence.js.map +1 -0
- package/package.json +13 -13
- package/sbom.json +250 -50
- package/src/agentdb-index.ts +26 -2
- package/src/amendment-trace.ts +6 -1
- package/src/cadence.ts +227 -0
- package/src/cli-flag-notice.ts +114 -0
- package/src/feature-adr-routing.ts +1 -1
- package/src/index.ts +8 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/operations.ts +60 -20
- package/src/publish.ts +98 -0
- package/src/recall-hook-policy.ts +15 -2
- package/src/recall-usage.ts +44 -1
- package/src/score.ts +30 -4
- package/src/tg-post.ts +137 -0
- package/src/usage.ts +132 -17
- package/src/writer-quiescence.ts +95 -0
package/src/publish.ts
CHANGED
|
@@ -89,6 +89,68 @@ function maxPublished(name: string, localVersion: string): string {
|
|
|
89
89
|
return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── workspace-floor preflight (feature workspace-dep-protocol, Codex P1) ─────
|
|
93
|
+
//
|
|
94
|
+
// Sibling deps are declared `workspace:^`, and pnpm rewrites them at pack time to `^<the sibling's
|
|
95
|
+
// DISK version>`. That version is not necessarily PUBLISHED: `--bump-only` stages versions on disk,
|
|
96
|
+
// and a later `--filter`ed publish of just the dependent would ship a floor nobody can install —
|
|
97
|
+
// the publish itself succeeds, and every consumer `npm install` then fails with ETARGET. Staged is
|
|
98
|
+
// not shipped; this preflight makes the difference a refusal instead of a broken release.
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pure half: which `workspace:`-declared deps of a package would pack to a floor that is neither
|
|
102
|
+
* being published in this batch nor already on the registry?
|
|
103
|
+
*
|
|
104
|
+
* Fail-closed by design: a probe that cannot answer (offline, 404) reports the floor as
|
|
105
|
+
* unpublished — a publish needs the network anyway, and refusing beats shipping ETARGET.
|
|
106
|
+
*/
|
|
107
|
+
export function findUnpublishedWorkspaceFloors(opts: {
|
|
108
|
+
readonly dependencies: Record<string, string> | undefined;
|
|
109
|
+
/** pnpm rewrites `workspace:` in peerDependencies at pack time too (Codex P2) — same hazard. */
|
|
110
|
+
readonly peerDependencies?: Record<string, string> | undefined;
|
|
111
|
+
/** name → version on DISK, for every package in the workspace (what pnpm packs the floor from). */
|
|
112
|
+
readonly workspaceVersions: ReadonlyMap<string, string>;
|
|
113
|
+
/**
|
|
114
|
+
* Names whose publish has LANDED (or, in a dry-run preview, would land) BEFORE this package.
|
|
115
|
+
* Static batch membership is not enough (Codex P1): a sibling that failed its own gates earlier
|
|
116
|
+
* in the batch has no published floor, and its dependents must fall through to the probe.
|
|
117
|
+
*/
|
|
118
|
+
readonly batch: ReadonlySet<string>;
|
|
119
|
+
readonly probe: (name: string, version: string) => boolean;
|
|
120
|
+
}): { name: string; version: string }[] {
|
|
121
|
+
const missing: { name: string; version: string }[] = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
// Sections are inspected INDEPENDENTLY, never object-merged: a plain peer range for the same
|
|
124
|
+
// sibling would overwrite a `workspace:^` dependency entry in a spread, and pnpm still rewrites
|
|
125
|
+
// the dependency section — the protocol in EITHER section makes the floor pack from disk.
|
|
126
|
+
const entries = [...Object.entries(opts.dependencies ?? {}), ...Object.entries(opts.peerDependencies ?? {})];
|
|
127
|
+
for (const [dep, spec] of entries) {
|
|
128
|
+
if (!String(spec).startsWith('workspace:')) continue;
|
|
129
|
+
if (seen.has(dep)) continue;
|
|
130
|
+
seen.add(dep);
|
|
131
|
+
if (opts.batch.has(dep)) continue; // publishes before this package (deps-first order)
|
|
132
|
+
const version = opts.workspaceVersions.get(dep);
|
|
133
|
+
if (version === undefined) {
|
|
134
|
+
// A workspace: spec naming a package that is not in the workspace — pnpm pack would die on
|
|
135
|
+
// it anyway, but die HERE with a name, not mid-batch.
|
|
136
|
+
missing.push({ name: dep, version: '(not in workspace)' });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!opts.probe(dep, version)) missing.push({ name: dep, version });
|
|
140
|
+
}
|
|
141
|
+
return missing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Registry probe: is exactly `name@version` published? Empty output / 404 / offline ⇒ no. */
|
|
145
|
+
function versionPublished(name: string, version: string): boolean {
|
|
146
|
+
try {
|
|
147
|
+
const out = execSync(`npm view ${name}@${version} version`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
|
|
148
|
+
return out === version;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
92
154
|
/**
|
|
93
155
|
* `execSync` throws an Error whose `.message` is only `Command failed: <cmd>` — the child's real output
|
|
94
156
|
* (the `npm ERR!` lines that say WHY a publish failed) sits on `.stdout` / `.stderr` and was being
|
|
@@ -331,6 +393,13 @@ export function publishPackages(
|
|
|
331
393
|
claimGate?: 'off' | 'warn' | 'error' | undefined;
|
|
332
394
|
/** ADR-001: `auto` (default) decides from the environment; `on` fails where it cannot work. */
|
|
333
395
|
provenance?: ProvenanceMode | undefined;
|
|
396
|
+
/**
|
|
397
|
+
* Floor probe injection for the workspace-floor preflight (see
|
|
398
|
+
* `findUnpublishedWorkspaceFloors`). Default: a real `npm view` probe, which runs only on LIVE
|
|
399
|
+
* publishes — dry-run stays offline, matching `maxPublished`. Injecting a probe also arms the
|
|
400
|
+
* preflight under dry-run, which is how the wiring test drives it without network.
|
|
401
|
+
*/
|
|
402
|
+
probeFloor?: ((name: string, version: string) => boolean) | undefined;
|
|
334
403
|
} = {},
|
|
335
404
|
): PublishReport {
|
|
336
405
|
// Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
|
|
@@ -346,6 +415,15 @@ export function publishPackages(
|
|
|
346
415
|
// freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
|
|
347
416
|
const ordered = orderByDependencies(filtered);
|
|
348
417
|
|
|
418
|
+
// Workspace-floor preflight inputs: the full workspace version map (what pnpm would pack each
|
|
419
|
+
// floor from), and the names whose publish has LANDED so far in this run — grown as the loop
|
|
420
|
+
// proceeds, never assumed from batch membership (Codex P1: a sibling that failed its own gates
|
|
421
|
+
// has no published floor, and static membership would still have covered its dependents).
|
|
422
|
+
const workspaceVersions = new Map(packages.map((p) => [p.name, p.version]));
|
|
423
|
+
const landedInBatch = new Set<string>();
|
|
424
|
+
const armFloorPreflight = opts.bumpOnly !== true && (opts.dryRun !== true || opts.probeFloor !== undefined);
|
|
425
|
+
const probeFloor = opts.probeFloor ?? versionPublished;
|
|
426
|
+
|
|
349
427
|
for (const pkg of ordered) {
|
|
350
428
|
const oldVersion = pkg.version;
|
|
351
429
|
// Bump from max(local, npm-published) so a locally-reverted version can't
|
|
@@ -370,6 +448,24 @@ export function publishPackages(
|
|
|
370
448
|
continue;
|
|
371
449
|
}
|
|
372
450
|
|
|
451
|
+
// Workspace-floor preflight (Codex P1, feature workspace-dep-protocol): a `workspace:^` dep
|
|
452
|
+
// packs to `^<sibling's DISK version>` — refuse if that floor is neither in this batch nor on
|
|
453
|
+
// the registry, or the publish succeeds and every consumer install dies with ETARGET.
|
|
454
|
+
if (armFloorPreflight) {
|
|
455
|
+
const manifest = JSON.parse(readFileSync(join(pkg.dir, 'package.json'), 'utf-8')) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
|
456
|
+
const unpublishedFloors = findUnpublishedWorkspaceFloors({ dependencies: manifest.dependencies, peerDependencies: manifest.peerDependencies, workspaceVersions, batch: landedInBatch, probe: probeFloor });
|
|
457
|
+
if (unpublishedFloors.length > 0) {
|
|
458
|
+
results.push({
|
|
459
|
+
name: pkg.name,
|
|
460
|
+
oldVersion,
|
|
461
|
+
newVersion,
|
|
462
|
+
status: 'error',
|
|
463
|
+
error: `workspace floor(s) not published: ${unpublishedFloors.map((f) => `${f.name}@${f.version}`).join(', ')}. Publish the sibling(s) first or include them in --filter — a staged disk version is not a shipped one.`,
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
373
469
|
// Pre-publish claim-check gate. Default `'warn'` per ADR-001: publishing SURFACES a
|
|
374
470
|
// README's untagged claims by default, but `'warn'` NEVER changes publish status, so the
|
|
375
471
|
// existing publish path is unaffected. `'error'` fails only THIS package when it carries a
|
|
@@ -405,6 +501,7 @@ export function publishPackages(
|
|
|
405
501
|
|
|
406
502
|
if (opts.dryRun) {
|
|
407
503
|
results.push({ name: pkg.name, oldVersion, newVersion, status: 'skipped', claimCheck: claimCheckSummary });
|
|
504
|
+
landedInBatch.add(pkg.name); // preview: this package passed its gates and WOULD land
|
|
408
505
|
continue;
|
|
409
506
|
}
|
|
410
507
|
|
|
@@ -511,6 +608,7 @@ export function publishPackages(
|
|
|
511
608
|
});
|
|
512
609
|
|
|
513
610
|
results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary });
|
|
611
|
+
landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
|
|
514
612
|
} catch (err) {
|
|
515
613
|
// The version was written BEFORE build+publish; on any failure restore the
|
|
516
614
|
// original package.json (and README, if we rewrote its version) so a failed
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* where an irrelevant English one reaches 0.254. A single floor still works, with a thin 0.032
|
|
27
27
|
* margin; per-language floors triple it. Hence the defaults below.
|
|
28
28
|
*
|
|
29
|
-
* The turns that must stay silent do
|
|
29
|
+
* The turns that must stay silent do (2026-07-09 numbers; re-measured 2026-08-24 — «спасибо» rose
|
|
30
|
+
* to 0.416 but is cut by the SIGNAL gate before any floor, and «какой статус?» rose to 0.386, which
|
|
31
|
+
* is what forced the recalibration above): both under
|
|
30
32
|
* every floor here.
|
|
31
33
|
*
|
|
32
34
|
* @packageDocumentation
|
|
@@ -46,7 +48,18 @@ export interface RecallFloors {
|
|
|
46
48
|
* slightly closer to any Latin text than two unrelated Latin texts are to each other — the baseline,
|
|
47
49
|
* not the signal, is what shifts.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
// RECALIBRATED 2026-08-24 on the LIVE 281-pattern store, end to end through `dz recall --json`
|
|
52
|
+
// (the closeness feature made the true cosine visible, which is what exposed the drift): over the
|
|
53
|
+
// probes that actually REACH the floor — the signal gate cuts "спасибо"/"thanks" first —
|
|
54
|
+
// ru: min(relevant)=0.409, max(irrelevant)=0.386 ("какой статус?", ABOVE the old 0.38 floor);
|
|
55
|
+
// en: min(relevant)=0.413, max(irrelevant)=0.332 (nonsense scored 0.327, above the old 0.31).
|
|
56
|
+
// The 2026-07-09 floors were calibrated on 103 patterns; at 281 the irrelevant tail rose. The RU
|
|
57
|
+
// window is now THIN (+0.023) — an honest limit, not a solved problem: it narrows again as the
|
|
58
|
+
// store grows, and the next recalibration should follow the next major store growth.
|
|
59
|
+
// en is 0.36 rather than the live midpoint 0.37 because the hermetic fixture's weakest relevant
|
|
60
|
+
// probe sits at 0.369, and a floor above it would fail the calibration test that guards this file.
|
|
61
|
+
// Probe set + raw results: test/fixtures/recall-floor-live-2026-08-24.json.
|
|
62
|
+
export const DEFAULT_RECALL_FLOORS: RecallFloors = { ru: 0.40, en: 0.36 };
|
|
50
63
|
|
|
51
64
|
/** Max hits injected into a turn. Three is the ADR default; more is noise, not context. */
|
|
52
65
|
export const DEFAULT_RECALL_HOOK_LIMIT = 3;
|
package/src/recall-usage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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 &' });
|
|
79
|
+
}
|
|
80
|
+
if (/</.test(outside.replace(/</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
|
|
444
|
-
//
|
|
445
|
-
//
|
|
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
|
-
|
|
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:
|
|
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 };
|