@event4u/agent-config 9.31.0 → 9.33.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +146 -0
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +17 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-PMQ6Z3T3.js} +1642 -165
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +308 -227
- package/dist/cli-delegate/cmd_doctor.js +4 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +160 -58
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +30 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1136 -690
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3688 -1159
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +44 -3
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/carrier_divergence.ts +98 -27
- package/src/scripts/_lib/envelope_grounding.ts +206 -0
- package/src/scripts/_lib/host_capability.ts +79 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +104 -29
- package/src/scripts/report_conformance_funnel.ts +51 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
|
@@ -189,6 +189,17 @@ function _serialise_response(r: CouncilResponse): Record<string, unknown> {
|
|
|
189
189
|
const payload: Record<string, unknown> = {
|
|
190
190
|
provider: r.provider,
|
|
191
191
|
model: r.model,
|
|
192
|
+
// `model` stays the REQUESTED id — the tier decision was made against
|
|
193
|
+
// it. `model_served` is what the provider reported answering with, so a
|
|
194
|
+
// silent alias or substitution is visible instead of being recorded as
|
|
195
|
+
// the model that never ran. Attribution-only: no consumer routes on it.
|
|
196
|
+
//
|
|
197
|
+
// Additive on the MANIFEST's own response row, which is this artefact —
|
|
198
|
+
// NOT a `cost-summary/v1` field: that contract describes
|
|
199
|
+
// `agents/cost-tracking/sessions.jsonl`, a different file with a
|
|
200
|
+
// different producer. Absent on manifests written before this, and `''`
|
|
201
|
+
// for every transport that reports no served id.
|
|
202
|
+
model_served: r.model_served,
|
|
192
203
|
input_tokens: r.input_tokens,
|
|
193
204
|
output_tokens: r.output_tokens,
|
|
194
205
|
latency_ms: r.latency_ms,
|
|
@@ -48,10 +48,20 @@ import type { ExternalAIClient } from './ai_council/clients.js';
|
|
|
48
48
|
import { buildOrchestrationLine } from './_lib/orchestration_record.js';
|
|
49
49
|
import { DEFAULT_DIR } from './orchestration_record.js';
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Placeholder written into `AskResult.model` when the member reported no id.
|
|
53
|
+
* Named so the audit path can recognise and refuse it rather than passing a
|
|
54
|
+
* sentinel off as a real model id.
|
|
55
|
+
*/
|
|
56
|
+
export const UNKNOWN_MODEL = 'unknown';
|
|
57
|
+
|
|
51
58
|
export interface AskResult {
|
|
52
59
|
answer: string;
|
|
53
60
|
member: string;
|
|
61
|
+
/** The REQUESTED model id — what the member was configured to call. */
|
|
54
62
|
model: string;
|
|
63
|
+
/** The model the provider reported serving; `''` when it reports none. */
|
|
64
|
+
model_served: string;
|
|
55
65
|
input_tokens: number;
|
|
56
66
|
output_tokens: number;
|
|
57
67
|
latency_ms: number;
|
|
@@ -94,6 +104,14 @@ function recordAskLine(auditDir: string, result: AskResult | null, member: strin
|
|
|
94
104
|
dispatch_tokens: result === null ? null : result.input_tokens + result.output_tokens,
|
|
95
105
|
wall_clock_ms: result === null ? 0 : Math.max(0, Math.round(result.latency_ms)),
|
|
96
106
|
agent_combo: [member || 'none'],
|
|
107
|
+
// `AskResult.model` substitutes an `'unknown'` SENTINEL when the member
|
|
108
|
+
// reported no id (below). That sentinel must never reach the audit line
|
|
109
|
+
// as a model_requested: `modelDivergent` treats any non-empty string as
|
|
110
|
+
// a real id, so it would fabricate a `true` on a line whose entire
|
|
111
|
+
// purpose is making real substitutions credible. null keeps the
|
|
112
|
+
// three-valued semantics — and the field's own "id only" contract.
|
|
113
|
+
model_requested: result === null || result.model === UNKNOWN_MODEL ? null : result.model,
|
|
114
|
+
model_served: result === null ? null : result.model_served,
|
|
97
115
|
origin: 'dispatch-economy-2026',
|
|
98
116
|
ts,
|
|
99
117
|
id,
|
|
@@ -145,7 +163,8 @@ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<As
|
|
|
145
163
|
{
|
|
146
164
|
answer: text,
|
|
147
165
|
member: provider,
|
|
148
|
-
model: r.model ??
|
|
166
|
+
model: r.model ?? UNKNOWN_MODEL,
|
|
167
|
+
model_served: r.model_served ?? '',
|
|
149
168
|
input_tokens: r.input_tokens ?? 0,
|
|
150
169
|
output_tokens: r.output_tokens ?? 0,
|
|
151
170
|
latency_ms: r.latency_ms ?? 0,
|
|
@@ -88,6 +88,25 @@ export interface ColdStartStats {
|
|
|
88
88
|
first_call_cache_read_share: number;
|
|
89
89
|
/** Σ(first-call cache_creation_input_tokens) / Σ(all subagent cache_creation_input_tokens) — the C-1 metric. */
|
|
90
90
|
cold_start_share_of_write_volume: number;
|
|
91
|
+
/**
|
|
92
|
+
* The write-share SIGNATURE across ALL subagent records (not just first
|
|
93
|
+
* calls): how the billable input splits between cache writes, cache reads,
|
|
94
|
+
* and uncached input.
|
|
95
|
+
*
|
|
96
|
+
* The three shares are over the same denominator and sum to 1 (up to
|
|
97
|
+
* float error) whenever any billable input exists, which is what makes it
|
|
98
|
+
* a signature rather than three unrelated ratios — a workload that is
|
|
99
|
+
* mostly `write` is paying the cache premium without collecting the read
|
|
100
|
+
* discount, and that is invisible in `cold_start_share_of_write_volume`,
|
|
101
|
+
* which only says how much of the writing happened on a first call.
|
|
102
|
+
*
|
|
103
|
+
* All three are 0 when there is no billable input at all. That is the
|
|
104
|
+
* empty-corpus reading, NOT "a perfectly uncached workload" — read it
|
|
105
|
+
* beside `legs`.
|
|
106
|
+
*/
|
|
107
|
+
write_share_of_billable: number;
|
|
108
|
+
read_share_of_billable: number;
|
|
109
|
+
uncached_share_of_billable: number;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
/**
|
|
@@ -132,12 +151,22 @@ export function computeColdStarts(records: readonly TranscriptRecord[]): ColdSta
|
|
|
132
151
|
const sumFirstCallBillable = firstCalls.reduce((s, r) => s + billableInputTokens(r.usage), 0);
|
|
133
152
|
const sumFirstCallWrite = firstCalls.reduce((s, r) => s + r.usage.cache_creation_input_tokens, 0);
|
|
134
153
|
|
|
154
|
+
// Signature over ALL subagent records — the shape of the workload, where
|
|
155
|
+
// the metrics above describe only its first calls.
|
|
156
|
+
const sumBillable = subagentRecords.reduce((s, r) => s + billableInputTokens(r.usage), 0);
|
|
157
|
+
const sumWrite = totalWriteVolume;
|
|
158
|
+
const sumRead = subagentRecords.reduce((s, r) => s + r.usage.cache_read_input_tokens, 0);
|
|
159
|
+
const sumUncached = subagentRecords.reduce((s, r) => s + r.usage.input_tokens, 0);
|
|
160
|
+
|
|
135
161
|
return {
|
|
136
162
|
legs: firstCalls.length,
|
|
137
163
|
median_first_call_written_or_uncached: median(writtenOrUncached),
|
|
138
164
|
mean_first_call_written_or_uncached: mean(writtenOrUncached),
|
|
139
165
|
first_call_cache_read_share: sumFirstCallBillable > 0 ? sumFirstCallRead / sumFirstCallBillable : 0,
|
|
140
166
|
cold_start_share_of_write_volume: totalWriteVolume > 0 ? sumFirstCallWrite / totalWriteVolume : 0,
|
|
167
|
+
write_share_of_billable: sumBillable > 0 ? sumWrite / sumBillable : 0,
|
|
168
|
+
read_share_of_billable: sumBillable > 0 ? sumRead / sumBillable : 0,
|
|
169
|
+
uncached_share_of_billable: sumBillable > 0 ? sumUncached / sumBillable : 0,
|
|
141
170
|
};
|
|
142
171
|
}
|
|
143
172
|
|
|
@@ -737,6 +766,15 @@ export function renderText(r: Report): string {
|
|
|
737
766
|
);
|
|
738
767
|
out.push(` first_call_cache_read_share=${pct(cs.first_call_cache_read_share)}`);
|
|
739
768
|
out.push(` cold_start_share_of_subagent_write_volume=${pct(cs.cold_start_share_of_write_volume)}`);
|
|
769
|
+
// Write-share signature over ALL subagent records: the three shares sum to
|
|
770
|
+
// 100% and say where the billable input actually went. A write-heavy split
|
|
771
|
+
// is paying the cache premium without collecting the read discount — which
|
|
772
|
+
// the cold-start metrics above cannot show, since they only look at first
|
|
773
|
+
// calls. All-zero means no billable input at all; read it beside legs.
|
|
774
|
+
out.push(
|
|
775
|
+
` write_share=${pct(cs.write_share_of_billable)} read_share=${pct(cs.read_share_of_billable)}` +
|
|
776
|
+
` uncached_share=${pct(cs.uncached_share_of_billable)} (of billable input, all subagent records)`,
|
|
777
|
+
);
|
|
740
778
|
out.push('');
|
|
741
779
|
|
|
742
780
|
out.push(`Per-agent breakdown: ${r.by_agent.length} distinct subagent legs (--format json carries all; top 10 by weighted_input_units below)`);
|
|
@@ -114,7 +114,13 @@ export function highlight_contradictions(
|
|
|
114
114
|
return out;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Exported so the advisory-vs-blocking decision is a fixture, not a claim.
|
|
119
|
+
* `stale_draft_labels` firing while the exit code stays 0 is the behaviour the
|
|
120
|
+
* cadence blocker adjudicates; pinning it here makes either branch of that
|
|
121
|
+
* decision a one-line diff with a test that notices.
|
|
122
|
+
*/
|
|
123
|
+
export function main(argv: readonly string[]): number {
|
|
118
124
|
let version: string | null = null;
|
|
119
125
|
let from: string | null = null;
|
|
120
126
|
let to = 'HEAD';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import * as os from 'node:os';
|
|
3
4
|
import * as path from 'node:path';
|
|
@@ -45,3 +46,45 @@ describe('check_secret_leak — .secret-allow', () => {
|
|
|
45
46
|
expect(readAllowFile(tmp)).toEqual([]);
|
|
46
47
|
});
|
|
47
48
|
});
|
|
49
|
+
|
|
50
|
+
describe('check_secret_leak — R2 review-input packages are a derived copy', () => {
|
|
51
|
+
let tmp: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'secret-review-input-')));
|
|
55
|
+
execFileSync('git', ['init', '--quiet'], { cwd: tmp, stdio: 'ignore' });
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function track(rel: string, body: string): void {
|
|
62
|
+
const abs = path.join(tmp, rel);
|
|
63
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
64
|
+
fs.writeFileSync(abs, body);
|
|
65
|
+
execFileSync('git', ['add', '--', rel], { cwd: tmp, stdio: 'ignore' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const FAKE_KEY = 'aws = AKIA1234567890ABCDEF\n';
|
|
69
|
+
|
|
70
|
+
it('skips a diff copied into a .review-input package', () => {
|
|
71
|
+
// The dispatcher copies the branch diff VERBATIM, so a fixture-legal
|
|
72
|
+
// fake key is laundered out of the tests/ carve-out into a tracked
|
|
73
|
+
// path. Scanning the copy adds no coverage and reds the gate.
|
|
74
|
+
track('agents/evidence/reviews/branch.review-input/diff.patch', FAKE_KEY);
|
|
75
|
+
expect(scanRepo(tmp, 'all')).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('still catches the same key at a normal path — the exclusion is narrow', () => {
|
|
79
|
+
track('src/scripts/thing.ts', FAKE_KEY);
|
|
80
|
+
const hits = scanRepo(tmp, 'all');
|
|
81
|
+
expect(hits).toHaveLength(1);
|
|
82
|
+
expect(hits[0]?.file).toBe('src/scripts/thing.ts');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not exclude the findings artefact itself — only the input package', () => {
|
|
86
|
+
// `<slug>.findings.md` is authored prose, not a copied diff.
|
|
87
|
+
track('agents/evidence/reviews/branch.findings.md', FAKE_KEY);
|
|
88
|
+
expect(scanRepo(tmp, 'all')).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -52,6 +52,18 @@ const DEFAULT_EXCLUDE: readonly RegExp[] = [
|
|
|
52
52
|
/(^|\/)\.claude(\/|$)/,
|
|
53
53
|
/(^|\/)fixtures(\/|$)/,
|
|
54
54
|
/(^|\/)tests(\/|$)/, // test + golden + parity data legitimately holds secret-shaped strings
|
|
55
|
+
// R2 review-input packages: a VERBATIM copy of the branch diff, written by
|
|
56
|
+
// `dispatch_r2_reviewer`. Every byte in it already exists in the branch and
|
|
57
|
+
// is scanned at its own path under its own exclusions, so scanning the copy
|
|
58
|
+
// adds no coverage — and it DEFEATS the two carve-outs above by
|
|
59
|
+
// construction: a secret-shaped string that is legal in a test fixture gets
|
|
60
|
+
// laundered into a scanned path the moment a completion review runs. That
|
|
61
|
+
// is not hypothetical; it red this gate on the PR that added this line.
|
|
62
|
+
// What the exclusion cannot lose: content present only in the copy and
|
|
63
|
+
// nowhere else in the branch. The package is machine-generated from
|
|
64
|
+
// `git diff` and cannot invent content, and `--verify-current` re-derives
|
|
65
|
+
// its hashes, so a hand-edit is detectable rather than silent.
|
|
66
|
+
/(^|\/)[^/]+\.review-input(\/|$)/,
|
|
55
67
|
/(^|\/)__[a-z_]+__(\/|$)/, // snapshot dirs (e.g. __parity_snapshots__)
|
|
56
68
|
/\.test\.[a-z]+$/,
|
|
57
69
|
/\.spec\.[a-z]+$/,
|
|
@@ -174,6 +174,79 @@ export interface ScanReport {
|
|
|
174
174
|
rate: RateRecord;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export type CheckId = Violation["check"];
|
|
178
|
+
|
|
179
|
+
export const CHECK_IDS: readonly CheckId[] = [
|
|
180
|
+
"language-pin",
|
|
181
|
+
"git-authorization",
|
|
182
|
+
"vacuous-evidence",
|
|
183
|
+
"evidence-steering",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What each mechanised check actually detects, in the words a reader needs in
|
|
188
|
+
* order to decide whether a hit is a defect or a false read. `--why` prints
|
|
189
|
+
* this ALONGSIDE the hits rather than instead of them: a count with no
|
|
190
|
+
* definition is the shape that let a figure like "109 divergent pairs" travel
|
|
191
|
+
* through five reviews unchallenged.
|
|
192
|
+
*/
|
|
193
|
+
export const CHECK_MEANINGS: Record<CheckId, string> = {
|
|
194
|
+
"language-pin":
|
|
195
|
+
"An assistant turn whose user-visible prose is not in the language that " +
|
|
196
|
+
"turn's own pin named. Carries provenance (turns_since_prompt, " +
|
|
197
|
+
"compaction_since_prompt) so a pin that was ABSENT because compaction " +
|
|
198
|
+
"removed it stays distinguishable from one that was present and ignored.",
|
|
199
|
+
"git-authorization":
|
|
200
|
+
"A gated git operation performed in a session where no authorization for " +
|
|
201
|
+
"that operation was observed. Authorization is per-operation and never " +
|
|
202
|
+
"carries from one to the next.",
|
|
203
|
+
"vacuous-evidence":
|
|
204
|
+
"A CI poll read as settled without ever observing an in-flight run — the " +
|
|
205
|
+
"poll gap that reports a green settle over a window in which nothing was " +
|
|
206
|
+
"pending yet.",
|
|
207
|
+
"evidence-steering":
|
|
208
|
+
"A self-commissioned evaluation whose prompt pre-loaded its own verdict, " +
|
|
209
|
+
"or a second self-scoped evaluation of the same subject within one turn.",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `--why <id>` — trace one conformance id: what it detects, whether it fired in
|
|
214
|
+
* this window, and every hit with its session and detail.
|
|
215
|
+
*
|
|
216
|
+
* A check that did not fire prints as *did not fire*, never as silence. Zero is
|
|
217
|
+
* a real answer, and the difference between "clean" and "not measured" is the
|
|
218
|
+
* whole reason to print it.
|
|
219
|
+
*/
|
|
220
|
+
export function renderWhy(report: ScanReport, id: CheckId): string {
|
|
221
|
+
const hits = report.per_session.flatMap((s) => s.violations.filter((v) => v.check === id));
|
|
222
|
+
const lines: string[] = [
|
|
223
|
+
`conformance:why · ${id} · ${report.sessions} session(s) · ${report.store}`,
|
|
224
|
+
"",
|
|
225
|
+
" What it detects:",
|
|
226
|
+
` ${CHECK_MEANINGS[id]}`,
|
|
227
|
+
"",
|
|
228
|
+
];
|
|
229
|
+
if (hits.length === 0) {
|
|
230
|
+
lines.push(` Did NOT fire in this window (0 hits over ${report.sessions} session(s)).`);
|
|
231
|
+
lines.push(" A measured zero, not an unmeasured one — the check ran.");
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
lines.push(` Fired ${hits.length} time(s):`);
|
|
235
|
+
for (const v of hits) {
|
|
236
|
+
lines.push(` · ${v.session} @ ${v.at}`);
|
|
237
|
+
lines.push(` ${v.detail}`);
|
|
238
|
+
const since = (v as { turns_since_prompt?: number }).turns_since_prompt;
|
|
239
|
+
const compacted = (v as { compaction_since_prompt?: boolean }).compaction_since_prompt;
|
|
240
|
+
if (since !== undefined || compacted !== undefined) {
|
|
241
|
+
lines.push(
|
|
242
|
+
` provenance: turns_since_prompt=${since ?? "—"} · ` +
|
|
243
|
+
`compaction_since_prompt=${compacted ?? "—"}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
177
250
|
type Entry = Record<string, unknown>;
|
|
178
251
|
|
|
179
252
|
function _isObject(v: unknown): v is Record<string, unknown> {
|
|
@@ -641,6 +714,7 @@ export function main(argv?: string[]): number {
|
|
|
641
714
|
let json = false;
|
|
642
715
|
let out: string | null = null;
|
|
643
716
|
let record: string | null = null;
|
|
717
|
+
let why: string | null = null;
|
|
644
718
|
for (let i = 0; i < args.length; i += 1) {
|
|
645
719
|
const a = args[i];
|
|
646
720
|
if (a === "--store" && args[i + 1] !== undefined) {
|
|
@@ -651,6 +725,8 @@ export function main(argv?: string[]): number {
|
|
|
651
725
|
json = true;
|
|
652
726
|
} else if (a === "--output" && args[i + 1] !== undefined) {
|
|
653
727
|
out = args[++i] as string;
|
|
728
|
+
} else if (a === "--why" && args[i + 1] !== undefined) {
|
|
729
|
+
why = args[++i] as string;
|
|
654
730
|
} else if (a === "--record") {
|
|
655
731
|
// Opt-in, and a bare `--record` takes the default path rather than
|
|
656
732
|
// swallowing the next flag as a filename.
|
|
@@ -660,6 +736,13 @@ export function main(argv?: string[]): number {
|
|
|
660
736
|
: DEFAULT_RATE_SERIES;
|
|
661
737
|
}
|
|
662
738
|
}
|
|
739
|
+
if (why !== null && !CHECK_IDS.includes(why as CheckId)) {
|
|
740
|
+
process.stderr.write(
|
|
741
|
+
`conformance:why: unknown check id ${JSON.stringify(why)} — known ids: ` +
|
|
742
|
+
`${CHECK_IDS.join(", ")}\n`,
|
|
743
|
+
);
|
|
744
|
+
return 2;
|
|
745
|
+
}
|
|
663
746
|
const resolved = store ?? defaultStore(process.cwd());
|
|
664
747
|
if (!fs.existsSync(resolved)) {
|
|
665
748
|
process.stderr.write(`conformance:behavior: no transcript store at ${resolved}\n`);
|
|
@@ -675,6 +758,28 @@ export function main(argv?: string[]): number {
|
|
|
675
758
|
// fs.writeSync, not process.stdout.write: `process.exit()` below does not
|
|
676
759
|
// flush an async pipe write, so `--json | jq` reproducibly received a
|
|
677
760
|
// truncated 64 KB document with no error (measured: 85,169 vs 65,536 bytes).
|
|
761
|
+
if (why !== null) {
|
|
762
|
+
const id = why as CheckId;
|
|
763
|
+
fs.writeSync(
|
|
764
|
+
1,
|
|
765
|
+
json
|
|
766
|
+
? `${JSON.stringify(
|
|
767
|
+
{
|
|
768
|
+
check: id,
|
|
769
|
+
meaning: CHECK_MEANINGS[id],
|
|
770
|
+
store: report.store,
|
|
771
|
+
sessions: report.sessions,
|
|
772
|
+
hits: report.per_session.flatMap((s) =>
|
|
773
|
+
s.violations.filter((v) => v.check === id),
|
|
774
|
+
),
|
|
775
|
+
},
|
|
776
|
+
null,
|
|
777
|
+
2,
|
|
778
|
+
)}\n`
|
|
779
|
+
: `${renderWhy(report, id)}\n`,
|
|
780
|
+
);
|
|
781
|
+
return 0;
|
|
782
|
+
}
|
|
678
783
|
fs.writeSync(1, json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
|
|
679
784
|
return 0;
|
|
680
785
|
}
|
|
@@ -59,6 +59,19 @@ function modelTier(model) {
|
|
|
59
59
|
// cache_creation_input_tokens NOT covered by that split (older records that
|
|
60
60
|
// predate the TTL breakdown) is priced at the 5m rate — Anthropic's default
|
|
61
61
|
// TTL — rather than silently dropped.
|
|
62
|
+
// A tier with no PRICING row prices at zero. That zero is indistinguishable
|
|
63
|
+
// from a genuinely free message, so the ROW carries `rate_missing` and the run
|
|
64
|
+
// warns once on stderr — the alternative (throwing) would lose a whole session
|
|
65
|
+
// of real token counts over one unrecognised model id. Token counts are kept
|
|
66
|
+
// untouched precisely so a later re-pricing pass can backfill the cost.
|
|
67
|
+
// Every token class that costs money. A record where this is 0 costs 0 at any
|
|
68
|
+
// rate, so a missing rate cannot understate it.
|
|
69
|
+
function billableTokens(u) {
|
|
70
|
+
if (!u) return 0;
|
|
71
|
+
return (u.input_tokens || 0) + (u.output_tokens || 0)
|
|
72
|
+
+ (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
function costForUsage(tier, u) {
|
|
63
76
|
const p = PRICING[tier];
|
|
64
77
|
if (!p || !u) return 0;
|
|
@@ -163,6 +176,8 @@ function summarizeSession(files) {
|
|
|
163
176
|
let sessionId = null, cwd = null;
|
|
164
177
|
let totalSeen = 0;
|
|
165
178
|
const seen = new Set();
|
|
179
|
+
// Model ids that priced at zero because no PRICING tier claimed them.
|
|
180
|
+
const rateMissingModels = new Set();
|
|
166
181
|
|
|
167
182
|
for (const file of files) {
|
|
168
183
|
let text;
|
|
@@ -188,6 +203,15 @@ function summarizeSession(files) {
|
|
|
188
203
|
const tier = modelTier(model);
|
|
189
204
|
const u = usageTokens(m.message.usage);
|
|
190
205
|
const cost = costForUsage(tier, u);
|
|
206
|
+
// Key on the ACTUAL zero condition — `!PRICING[tier]`, what
|
|
207
|
+
// costForUsage returns 0 on — not on `tier === 'unknown'`. The two
|
|
208
|
+
// coincide only because `unknown` is today the sole tier modelTier()
|
|
209
|
+
// returns that PRICING lacks; adding a vendor family without a rate row
|
|
210
|
+
// would otherwise restore the exact silent zero this flag removes.
|
|
211
|
+
// The usage guard keeps the flag worth reading: a message with no
|
|
212
|
+
// billable tokens costs nothing at any rate, so flagging it would claim
|
|
213
|
+
// an understatement that is not there.
|
|
214
|
+
if (!PRICING[tier] && billableTokens(u) > 0) rateMissingModels.add(model);
|
|
191
215
|
|
|
192
216
|
const slot = byModel[model] || { tier, input_tokens: 0, output_tokens: 0,
|
|
193
217
|
cache_creation_input_tokens: 0, cache_read_input_tokens: 0, messages: 0, cost_usd: 0 };
|
|
@@ -213,6 +237,12 @@ function summarizeSession(files) {
|
|
|
213
237
|
const dedup_ratio = totalSeen > 0 ? (totalSeen - dedupedCount) / totalSeen : 0;
|
|
214
238
|
return { sessionId, cwd, startedAt: firstTs, endedAt: lastTs, messageCount,
|
|
215
239
|
byModel, byTier, byBucket, total_cost_usd: totalCost,
|
|
240
|
+
// `rate_missing` marks a row whose total_cost_usd is understated because
|
|
241
|
+
// at least one model priced at zero for want of a rate — never because the
|
|
242
|
+
// work was free. `rate_missing_models` carries the ids so a backfill has a
|
|
243
|
+
// shape to work against instead of re-deriving it from the transcript.
|
|
244
|
+
rate_missing: rateMissingModels.size > 0,
|
|
245
|
+
rate_missing_models: [...rateMissingModels].sort(),
|
|
216
246
|
totalRecordsSeen: totalSeen, dedupedRecordsCount: dedupedCount, dedup_ratio,
|
|
217
247
|
capturedAt: new Date().toISOString() };
|
|
218
248
|
}
|
|
@@ -237,6 +267,15 @@ function main() {
|
|
|
237
267
|
}
|
|
238
268
|
const files = collectSessionFiles(projectDir, sessionPath);
|
|
239
269
|
const summary = summarizeSession(files);
|
|
270
|
+
// ONE warning per run, before the quiet return: a suppressed report is a
|
|
271
|
+
// display choice, an understated cost figure is a data-integrity problem.
|
|
272
|
+
if (summary.rate_missing) {
|
|
273
|
+
console.error(
|
|
274
|
+
`cost-track: rate_missing — no price tier for ${summary.rate_missing_models.join(', ')}; ` +
|
|
275
|
+
`those messages priced at $0 and the session total is understated. ` +
|
|
276
|
+
`Token counts are kept, so the row can be re-priced later.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
240
279
|
if (process.env.TRACK_OUT) writeFileSync(process.env.TRACK_OUT, JSON.stringify(summary, null, 2));
|
|
241
280
|
const store = process.env.TRACK_STORE || DEFAULT_STORE;
|
|
242
281
|
let res = { ok: false, reason: 'dry-run' };
|
|
@@ -18,6 +18,8 @@ import * as fs from 'node:fs';
|
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
|
|
21
|
+
import { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER_5M } from './ai_council/pricing.js';
|
|
22
|
+
|
|
21
23
|
const _HERE = fileURLToPath(import.meta.url);
|
|
22
24
|
// src/scripts/cost_summary.ts → parents[2] is the repo root (mirrors
|
|
23
25
|
// `Path(__file__).resolve().parent.parent.parent` in the .py).
|
|
@@ -70,6 +72,13 @@ interface KvBucket {
|
|
|
70
72
|
cache_read_input_tokens: number;
|
|
71
73
|
cache_creation_input_tokens: number;
|
|
72
74
|
telegraph_delta_tokens: number;
|
|
75
|
+
// Source rows whose `total_cost_usd` is a FLOOR, not a figure: at least
|
|
76
|
+
// one message priced at $0 for want of a rate (`cost/track.mjs`
|
|
77
|
+
// `rate_missing`). Carried into every grouping because the understatement
|
|
78
|
+
// travels with the row — a summary that aggregates past it reports the
|
|
79
|
+
// silent zero one layer up, which is the defect the row-level flag exists
|
|
80
|
+
// to remove.
|
|
81
|
+
rate_missing_sessions: number;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
function _zero_kv(): KvBucket {
|
|
@@ -81,6 +90,7 @@ function _zero_kv(): KvBucket {
|
|
|
81
90
|
cache_read_input_tokens: 0,
|
|
82
91
|
cache_creation_input_tokens: 0,
|
|
83
92
|
telegraph_delta_tokens: 0,
|
|
93
|
+
rate_missing_sessions: 0,
|
|
84
94
|
};
|
|
85
95
|
}
|
|
86
96
|
|
|
@@ -94,6 +104,7 @@ interface ModelBucket {
|
|
|
94
104
|
// cache itself is model-scoped — see pricing.ts), so by_model carries it.
|
|
95
105
|
cache_read_input_tokens: number;
|
|
96
106
|
cache_creation_input_tokens: number;
|
|
107
|
+
rate_missing_sessions: number;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
function _zero_model(): ModelBucket {
|
|
@@ -104,15 +115,58 @@ function _zero_model(): ModelBucket {
|
|
|
104
115
|
output_tokens: 0,
|
|
105
116
|
cache_read_input_tokens: 0,
|
|
106
117
|
cache_creation_input_tokens: 0,
|
|
118
|
+
rate_missing_sessions: 0,
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
/**
|
|
123
|
+
* What the prompt cache bought, in INPUT-TOKEN EQUIVALENTS — not dollars.
|
|
124
|
+
*
|
|
125
|
+
* A cached read bills at `CACHE_READ_MULTIPLIER` of the input rate, so each
|
|
126
|
+
* read token saved `1 - multiplier` of one. A cache write bills at a premium,
|
|
127
|
+
* so each written token cost an extra `multiplier - 1`. Netting the two is the
|
|
128
|
+
* only honest single number here.
|
|
129
|
+
*
|
|
130
|
+
* Deliberately NOT priced in USD: the summary aggregates across models with
|
|
131
|
+
* different input rates, and `totals` carries no per-model token split to
|
|
132
|
+
* apply them to — a dollar figure would have to pick one rate and would be
|
|
133
|
+
* wrong for every other model in the row.
|
|
134
|
+
*
|
|
135
|
+
* The write premium uses the 5-minute multiplier. Rows carry no TTL split, and
|
|
136
|
+
* 5m is both Anthropic's default and the assumption `cost/track.mjs` already
|
|
137
|
+
* makes for unaccounted writes — so the two cost paths stay consistent rather
|
|
138
|
+
* than disagreeing quietly. A 1h-heavy workload therefore reads as slightly
|
|
139
|
+
* OPTIMISTIC, which is stated in the contract.
|
|
140
|
+
*/
|
|
141
|
+
function _cacheSavings(readTokens: number, writeTokens: number): number {
|
|
142
|
+
const saved = readTokens * (1 - CACHE_READ_MULTIPLIER);
|
|
143
|
+
const premium = writeTokens * (CACHE_WRITE_MULTIPLIER_5M - 1);
|
|
144
|
+
return Math.round(saved - premium);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** UTC calendar date (`YYYY-MM-DD`) of a row, or `'unknown'`. */
|
|
148
|
+
function _rowDate(row: Row): string {
|
|
149
|
+
// `startedAt` is the row's own first-message timestamp (track.mjs); the
|
|
150
|
+
// snake_case spelling is accepted for the same reason `session_id` is.
|
|
151
|
+
const raw = row['startedAt'] ?? row['started_at'];
|
|
152
|
+
if (typeof raw !== 'string' || !raw) {
|
|
153
|
+
return 'unknown';
|
|
154
|
+
}
|
|
155
|
+
const parsed = new Date(raw);
|
|
156
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
157
|
+
return 'unknown';
|
|
158
|
+
}
|
|
159
|
+
return parsed.toISOString().slice(0, 10);
|
|
160
|
+
}
|
|
161
|
+
|
|
110
162
|
/** Aggregate rows into the cost-summary structure (PyFloat-aware output). */
|
|
111
163
|
export function aggregate(rows: Row[]): Json {
|
|
112
164
|
// Insertion-ordered maps to mirror Python's dict ordering + sorted().
|
|
113
165
|
const by_sess = new Map<string, KvBucket>();
|
|
114
166
|
const by_conv = new Map<string, KvBucket>();
|
|
115
167
|
const by_model = new Map<string, ModelBucket>();
|
|
168
|
+
const by_date = new Map<string, KvBucket>();
|
|
169
|
+
const rate_missing_models = new Set<string>();
|
|
116
170
|
const totals = _zero_kv();
|
|
117
171
|
|
|
118
172
|
for (const row of rows) {
|
|
@@ -125,10 +179,17 @@ export function aggregate(rows: Row[]): Json {
|
|
|
125
179
|
const cread = _int(row['cache_read_input_tokens']);
|
|
126
180
|
const cwrite = _int(row['cache_creation_input_tokens']);
|
|
127
181
|
const delta = _delta(row);
|
|
182
|
+
// A row is understated when its producer says so. `_pyTruthy` keeps the
|
|
183
|
+
// absent-reading identical to every other additive field: no flag → 0.
|
|
184
|
+
const rateMissing = _pyTruthy(row['rate_missing']) ? 1 : 0;
|
|
185
|
+
for (const id of _strList(row['rate_missing_models'])) {
|
|
186
|
+
rate_missing_models.add(id);
|
|
187
|
+
}
|
|
128
188
|
|
|
129
189
|
const sessBucket = _getOr(by_sess, sid, _zero_kv);
|
|
130
190
|
const convBucket = _getOr(by_conv, cid, _zero_kv);
|
|
131
|
-
|
|
191
|
+
const dateBucket = _getOr(by_date, _rowDate(row), _zero_kv);
|
|
192
|
+
for (const bucket of [sessBucket, convBucket, dateBucket, totals]) {
|
|
132
193
|
bucket.sessions += 1;
|
|
133
194
|
bucket.total_cost_usd += cost;
|
|
134
195
|
bucket.input_tokens += itok;
|
|
@@ -136,6 +197,7 @@ export function aggregate(rows: Row[]): Json {
|
|
|
136
197
|
bucket.cache_read_input_tokens += cread;
|
|
137
198
|
bucket.cache_creation_input_tokens += cwrite;
|
|
138
199
|
bucket.telegraph_delta_tokens += delta;
|
|
200
|
+
bucket.rate_missing_sessions += rateMissing;
|
|
139
201
|
}
|
|
140
202
|
const m = _getOr(by_model, model, _zero_model);
|
|
141
203
|
m.sessions += 1;
|
|
@@ -144,6 +206,7 @@ export function aggregate(rows: Row[]): Json {
|
|
|
144
206
|
m.output_tokens += otok;
|
|
145
207
|
m.cache_read_input_tokens += cread;
|
|
146
208
|
m.cache_creation_input_tokens += cwrite;
|
|
209
|
+
m.rate_missing_sessions += rateMissing;
|
|
147
210
|
}
|
|
148
211
|
|
|
149
212
|
return {
|
|
@@ -159,6 +222,15 @@ export function aggregate(rows: Row[]): Json {
|
|
|
159
222
|
telegraph_delta_tokens: totals.telegraph_delta_tokens,
|
|
160
223
|
telegraph_multiplier_version: MULTIPLIER_VERSION,
|
|
161
224
|
telegraph_multiplier_active: MULTIPLIER_ACTIVE,
|
|
225
|
+
// Additive per the schema's own rule; token-equivalents, not USD.
|
|
226
|
+
cache_savings_input_token_equivalents: _cacheSavings(
|
|
227
|
+
totals.cache_read_input_tokens,
|
|
228
|
+
totals.cache_creation_input_tokens,
|
|
229
|
+
),
|
|
230
|
+
// The understated-cost qualification, surfaced instead of
|
|
231
|
+
// aggregated past: a non-zero count means total_cost_usd is a floor.
|
|
232
|
+
rate_missing_sessions: totals.rate_missing_sessions,
|
|
233
|
+
rate_missing_models: [...rate_missing_models].sort(),
|
|
162
234
|
},
|
|
163
235
|
by_session: _sortedEntries(by_sess).map(([k, v]) => ({
|
|
164
236
|
key: k,
|
|
@@ -169,6 +241,7 @@ export function aggregate(rows: Row[]): Json {
|
|
|
169
241
|
cache_read_input_tokens: v.cache_read_input_tokens,
|
|
170
242
|
cache_creation_input_tokens: v.cache_creation_input_tokens,
|
|
171
243
|
telegraph_delta_tokens: v.telegraph_delta_tokens,
|
|
244
|
+
rate_missing_sessions: v.rate_missing_sessions,
|
|
172
245
|
})),
|
|
173
246
|
by_conversation: _sortedEntries(by_conv).map(([k, v]) => ({
|
|
174
247
|
key: k,
|
|
@@ -179,6 +252,22 @@ export function aggregate(rows: Row[]): Json {
|
|
|
179
252
|
cache_read_input_tokens: v.cache_read_input_tokens,
|
|
180
253
|
cache_creation_input_tokens: v.cache_creation_input_tokens,
|
|
181
254
|
telegraph_delta_tokens: v.telegraph_delta_tokens,
|
|
255
|
+
rate_missing_sessions: v.rate_missing_sessions,
|
|
256
|
+
})),
|
|
257
|
+
// Ordered by date ascending — `_sortedEntries` is codepoint order, and
|
|
258
|
+
// ISO `YYYY-MM-DD` sorts chronologically under it. `'unknown'` sorts
|
|
259
|
+
// last (`u` > any digit), so a row with no timestamp never displaces a
|
|
260
|
+
// real day.
|
|
261
|
+
by_date: _sortedEntries(by_date).map(([k, v]) => ({
|
|
262
|
+
key: k,
|
|
263
|
+
sessions: v.sessions,
|
|
264
|
+
total_cost_usd: new PyFloat(v.total_cost_usd),
|
|
265
|
+
input_tokens: v.input_tokens,
|
|
266
|
+
output_tokens: v.output_tokens,
|
|
267
|
+
cache_read_input_tokens: v.cache_read_input_tokens,
|
|
268
|
+
cache_creation_input_tokens: v.cache_creation_input_tokens,
|
|
269
|
+
telegraph_delta_tokens: v.telegraph_delta_tokens,
|
|
270
|
+
rate_missing_sessions: v.rate_missing_sessions,
|
|
182
271
|
})),
|
|
183
272
|
by_model: _sortedEntries(by_model).map(([k, v]) => ({
|
|
184
273
|
model: k,
|
|
@@ -188,6 +277,7 @@ export function aggregate(rows: Row[]): Json {
|
|
|
188
277
|
output_tokens: v.output_tokens,
|
|
189
278
|
cache_read_input_tokens: v.cache_read_input_tokens,
|
|
190
279
|
cache_creation_input_tokens: v.cache_creation_input_tokens,
|
|
280
|
+
rate_missing_sessions: v.rate_missing_sessions,
|
|
191
281
|
})),
|
|
192
282
|
};
|
|
193
283
|
}
|
|
@@ -219,6 +309,19 @@ function _str(value: unknown): string {
|
|
|
219
309
|
return String(value);
|
|
220
310
|
}
|
|
221
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Read a list-of-strings field defensively — absent, wrong-typed, or holding
|
|
314
|
+
* non-string members all read as "nothing to add" rather than throwing or
|
|
315
|
+
* coercing. A summary must never fail on a malformed source row; it skips it,
|
|
316
|
+
* exactly as `_load` skips an unparseable line.
|
|
317
|
+
*/
|
|
318
|
+
function _strList(value: unknown): string[] {
|
|
319
|
+
if (!Array.isArray(value)) {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
return value.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
323
|
+
}
|
|
324
|
+
|
|
222
325
|
/** Mirror `int(row.get(k) or 0)` — falsy → 0; numeric/string → truncated int. */
|
|
223
326
|
function _int(value: unknown): number {
|
|
224
327
|
if (!_pyTruthy(value)) {
|