@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { type Row, render, type Screen } from "../../platform/screen.ts";
|
|
2
|
+
import { PLAIN, type Style } from "../../platform/style.ts";
|
|
3
|
+
import type { SessionRollup } from "./observability.store.ts";
|
|
4
|
+
import type { ObsEvent } from "./observability.types.ts";
|
|
5
|
+
|
|
6
|
+
export type ProviderTotals = {
|
|
7
|
+
provider: string;
|
|
8
|
+
events: number;
|
|
9
|
+
signals: number;
|
|
10
|
+
denials: number;
|
|
11
|
+
gates: { pass: number; fail: number };
|
|
12
|
+
estimated_cost_usd: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function emptyTotals(provider: string): ProviderTotals {
|
|
16
|
+
return { provider, events: 0, signals: 0, denials: 0, gates: { pass: 0, fail: 0 }, estimated_cost_usd: 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function groupByProvider(events: ObsEvent[]): Record<string, ProviderTotals> {
|
|
20
|
+
const groups: Record<string, ProviderTotals> = {};
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
const totals = groups[event.provider] ?? emptyTotals(event.provider);
|
|
23
|
+
totals.events += 1;
|
|
24
|
+
if (event.level === "signal") {
|
|
25
|
+
totals.signals += 1;
|
|
26
|
+
}
|
|
27
|
+
if (event.kind === "policy.deny") {
|
|
28
|
+
totals.denials += 1;
|
|
29
|
+
}
|
|
30
|
+
if (event.kind === "gate.outcome") {
|
|
31
|
+
if (event.attrs.passed) {
|
|
32
|
+
totals.gates.pass += 1;
|
|
33
|
+
} else {
|
|
34
|
+
totals.gates.fail += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (typeof event.gen_ai?.cost_usd === "number") {
|
|
38
|
+
totals.estimated_cost_usd += event.gen_ai.cost_usd;
|
|
39
|
+
}
|
|
40
|
+
groups[event.provider] = totals;
|
|
41
|
+
}
|
|
42
|
+
return groups;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* why: the count alone is not actionable — "seven interruptions" names no switch. The breakdown renders only when
|
|
47
|
+
* there is something in it, so a session that was never interrupted reads exactly as it did before.
|
|
48
|
+
*
|
|
49
|
+
* invariant: this reports the decisions the harness made. It does not know the operator's answer, and it cannot
|
|
50
|
+
* know whether a question it never asked would have helped — so it is a rate and an attribution, never a
|
|
51
|
+
* precision or a recall. Naming it after a metric it cannot compute is the class of claim this project keeps
|
|
52
|
+
* removing ([/decisions/ad-026.md](/decisions/ad-026.md)).
|
|
53
|
+
*/
|
|
54
|
+
function interruptionsByRule(rollup: SessionRollup): string {
|
|
55
|
+
const entries = Object.entries(rollup.shell.byRule ?? {}).sort((a, b) => b[1] - a[1]);
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
return entries.map(([rule, count]) => `| ↳ ${rule} | ${count} |`).join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* why: the question that decides something. A rail that never fired across a session is either working perfectly
|
|
64
|
+
* or was never needed, and either way it is paying for injected prose on every turn. Naming it is what makes
|
|
65
|
+
* deletion a decision instead of a feeling ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
66
|
+
*
|
|
67
|
+
* invariant: the active list is a parameter. Core cannot see which rails a project enabled without reading policy,
|
|
68
|
+
* and a report that guessed the list would accuse a rail that was never switched on.
|
|
69
|
+
*/
|
|
70
|
+
export function railsNeverFired(rollup: SessionRollup, activeRules: readonly string[]): string[] {
|
|
71
|
+
const fired = new Set(Object.keys(rollup.railsByRule ?? {}));
|
|
72
|
+
return activeRules.filter((rule) => !fired.has(rule)).sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* hazard: one line said `injected_chars` was "the price of the rails above, paid on every turn". On a host that
|
|
77
|
+
* drops context returned from its session-start hook it is paid never, and the durable rules file — which asks to be
|
|
78
|
+
* included on every request — was absent from the rollup entirely. The number was wrong in both directions there
|
|
79
|
+
* ([/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
80
|
+
*
|
|
81
|
+
* invariant: every number here was observed. The characters are counted from what the harness emitted and from the
|
|
82
|
+
* file it wrote; what the host then does with either is stated as the host's declaration, not as a measurement.
|
|
83
|
+
*/
|
|
84
|
+
export function costLines(rollup: SessionRollup): string[] {
|
|
85
|
+
if (rollup.injected_chars === 0 && rollup.durable_chars === 0) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
// why: its own section rather than a trailing line under the rails table, which is where it was — so a session
|
|
89
|
+
// with no active rail reported no cost while still paying one.
|
|
90
|
+
const lines = ["", "## Injected context", ""];
|
|
91
|
+
if (rollup.hook_context_reliable) {
|
|
92
|
+
lines.push(
|
|
93
|
+
`Injected at session start: ${rollup.injected_chars} characters. That is the price of the rails, paid on every turn.`,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(
|
|
97
|
+
`Emitted at session start: ${rollup.injected_chars} characters — this provider does not deliver context returned from that hook, so it is not what the model reads.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (rollup.durable_chars > 0) {
|
|
101
|
+
lines.push(
|
|
102
|
+
`Durable lessons view: ${rollup.durable_chars} characters, written as an always-applied rules file. That is what the provider is asked to include on every request.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function railActivity(rollup: SessionRollup, activeRules: readonly string[]): string {
|
|
109
|
+
const fired = Object.entries(rollup.railsByRule ?? {}).sort((a, b) => b[1] - a[1]);
|
|
110
|
+
const silent = railsNeverFired(rollup, activeRules);
|
|
111
|
+
if (fired.length === 0 && silent.length === 0) {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
const rows = [
|
|
115
|
+
"",
|
|
116
|
+
"## Rails",
|
|
117
|
+
"",
|
|
118
|
+
"| Rule | Fired |",
|
|
119
|
+
"|------|-------|",
|
|
120
|
+
...fired.map(([rule, count]) => `| ${rule} | ${count} |`),
|
|
121
|
+
...silent.map((rule) => `| ${rule} | 0 — enabled and never fired |`),
|
|
122
|
+
];
|
|
123
|
+
return rows.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function gateDetail(rollup: SessionRollup): string {
|
|
127
|
+
const entries = Object.entries(rollup.gatesByName ?? {}).sort((a, b) => b[1].fail - a[1].fail);
|
|
128
|
+
if (entries.length === 0) {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
return entries.map(([gate, s]) => `| ↳ ${gate} | ${s.pass} / ${s.fail} |`).join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* why: the question an operator actually asks when a turn takes thirty minutes. The runs column is what makes the
|
|
136
|
+
* multiplication visible: the gate cost is paid once per attempt, so six runs of a four-minute suite is the answer
|
|
137
|
+
* and no amount of hook tuning would have changed it ([/decisions/ad-033.md](/decisions/ad-033.md)).
|
|
138
|
+
*/
|
|
139
|
+
function gateTimeSection(rollup: SessionRollup): string {
|
|
140
|
+
const entries = Object.entries(rollup.gateTime ?? {}).sort((a, b) => b[1].totalMs - a[1].totalMs);
|
|
141
|
+
if (entries.length === 0) {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
const seconds = (ms: number): string => (ms / 1000).toFixed(1);
|
|
145
|
+
return [
|
|
146
|
+
"",
|
|
147
|
+
"## Gate time",
|
|
148
|
+
"",
|
|
149
|
+
"| Gate | Runs | Reused | Total s | Worst run s |",
|
|
150
|
+
"|------|------|--------|---------|-------------|",
|
|
151
|
+
...entries.map(
|
|
152
|
+
([gate, t]) =>
|
|
153
|
+
`| ${gate} | ${t.runs} | ${t.reused ?? 0} | ${seconds(t.totalMs)} | ${seconds(t.worstMs)} |`,
|
|
154
|
+
),
|
|
155
|
+
"",
|
|
156
|
+
"A gate's cost is paid once per attempt, so the total is the command's own time multiplied by how many times the",
|
|
157
|
+
"agent had to retry. Lowering it means a faster command or fewer failures, not a faster harness.",
|
|
158
|
+
"",
|
|
159
|
+
"**Reused** is a stop where nothing the gate reads had changed, so the previous verdict stood and the command did",
|
|
160
|
+
"not run. Those are the runs the harness did not make you pay for.",
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sessionReportMarkdown(rollup: SessionRollup, activeRules: readonly string[] = []): string {
|
|
165
|
+
const models = Object.entries(rollup.models)
|
|
166
|
+
.sort((a, b) => b[1] - a[1])
|
|
167
|
+
.map(([m, n]) => `| ${m} | ${n} |`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
const tools = Object.entries(rollup.tools)
|
|
170
|
+
.sort((a, b) => b[1].ok + b[1].fail - (a[1].ok + a[1].fail))
|
|
171
|
+
.map(([t, s]) => `| ${t} | ${s.ok} | ${s.fail} | ${Math.round(s.ms)} |`)
|
|
172
|
+
.join("\n");
|
|
173
|
+
const subs = Object.entries(rollup.subagents)
|
|
174
|
+
.map(([t, s]) => `| ${t} | ${s.count} | ${JSON.stringify(s.models)} |`)
|
|
175
|
+
.join("\n");
|
|
176
|
+
const costLabel = rollup.cost_incomplete
|
|
177
|
+
? `${rollup.estimated_cost_usd.toFixed(4)} (incomplete — some models lacked catalog rates)`
|
|
178
|
+
: rollup.estimated_cost_usd.toFixed(4);
|
|
179
|
+
|
|
180
|
+
return `# Harness session report
|
|
181
|
+
|
|
182
|
+
**Provider:** \`${rollup.provider}\`
|
|
183
|
+
**Session:** \`${rollup.session_id}\`
|
|
184
|
+
**Started:** ${rollup.started_at}
|
|
185
|
+
**Updated:** ${rollup.updated_at}
|
|
186
|
+
|
|
187
|
+
## Cost / tokens (estimated)
|
|
188
|
+
|
|
189
|
+
| Metric | Value |
|
|
190
|
+
|--------|-------|
|
|
191
|
+
| Estimated USD | ${costLabel} |
|
|
192
|
+
| Input tokens | ${rollup.input_tokens} |
|
|
193
|
+
| Output tokens | ${rollup.output_tokens} |
|
|
194
|
+
| Cost alert sent | ${rollup.cost_alert_sent} |
|
|
195
|
+
|
|
196
|
+
## Activity
|
|
197
|
+
|
|
198
|
+
| Metric | Value |
|
|
199
|
+
|--------|-------|
|
|
200
|
+
| Prompts | ${rollup.prompts} |
|
|
201
|
+
| Responses | ${rollup.responses} |
|
|
202
|
+
| Thoughts | ${rollup.thoughts} |
|
|
203
|
+
| Compactions | ${rollup.comped} |
|
|
204
|
+
| Policy denials | ${rollup.denials} |
|
|
205
|
+
| Gates pass/fail | ${rollup.gates.pass} / ${rollup.gates.fail} |
|
|
206
|
+
${gateDetail(rollup)}
|
|
207
|
+
| Shell allow/ask/deny | ${rollup.shell.allow} / ${rollup.shell.ask} / ${rollup.shell.deny} |
|
|
208
|
+
${interruptionsByRule(rollup)}
|
|
209
|
+
|
|
210
|
+
## Models
|
|
211
|
+
|
|
212
|
+
| Model | Events |
|
|
213
|
+
|-------|--------|
|
|
214
|
+
${models || "| — | 0 |"}
|
|
215
|
+
|
|
216
|
+
## Tools
|
|
217
|
+
|
|
218
|
+
| Tool | OK | Fail | ms |
|
|
219
|
+
|------|----|------|----|
|
|
220
|
+
${tools || "| — | 0 | 0 | 0 |"}
|
|
221
|
+
|
|
222
|
+
## Subagents
|
|
223
|
+
|
|
224
|
+
| Type | Count | Models |
|
|
225
|
+
|------|-------|--------|
|
|
226
|
+
${subs || "| — | 0 | {} |"}
|
|
227
|
+
|
|
228
|
+
## MCP tools
|
|
229
|
+
|
|
230
|
+
\`\`\`json
|
|
231
|
+
${JSON.stringify(rollup.mcp, null, 2)}
|
|
232
|
+
\`\`\`
|
|
233
|
+
${gateTimeSection(rollup)}
|
|
234
|
+
${railActivity(rollup, activeRules)}
|
|
235
|
+
${costLines(rollup).join("\n")}
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// why: the markdown is an artifact — written to a file and pasted into a pull request — so it stays plain. The
|
|
240
|
+
// terminal gets its own shape over the same rollup, because colouring one string for both would put escapes in
|
|
241
|
+
// the file ([/decisions/ad-063.md](/decisions/ad-063.md)).
|
|
242
|
+
/** The tools whose successes are recorded as shell events, so the tools table can never count them. */
|
|
243
|
+
export const SHELL_TOOLS = new Set(["Bash", "run_terminal_cmd", "terminal"]);
|
|
244
|
+
|
|
245
|
+
export function sessionReportScreen(rollup: SessionRollup): Screen {
|
|
246
|
+
const top = (counts: Record<string, number>, limit = 6): string[] =>
|
|
247
|
+
Object.entries(counts)
|
|
248
|
+
.sort((a, b) => b[1] - a[1])
|
|
249
|
+
.slice(0, limit)
|
|
250
|
+
.map(([name, count]) => `${name} ${count}`);
|
|
251
|
+
|
|
252
|
+
const costLabel = rollup.cost_incomplete
|
|
253
|
+
? `$${rollup.estimated_cost_usd.toFixed(4)} (incomplete — some models lacked catalog rates)`
|
|
254
|
+
: `$${rollup.estimated_cost_usd.toFixed(4)}`;
|
|
255
|
+
|
|
256
|
+
const shell = rollup.shell;
|
|
257
|
+
// hazard: `rollup.tools` is fed only by `tool.start|end|fail`. A successful shell call is `shell.end` and a
|
|
258
|
+
// failed one is `tool.fail`, so a shell tool's row can only ever show failures — it read `Bash: 0 ok, 23 fail`
|
|
259
|
+
// after hundreds of successful calls. The `shell` row above already answers for it, correctly
|
|
260
|
+
// ([/decisions/ad-064.md](/decisions/ad-064.md)).
|
|
261
|
+
const toolRows: Row[] = Object.entries(rollup.tools)
|
|
262
|
+
.filter(([tool]) => !SHELL_TOOLS.has(tool))
|
|
263
|
+
.sort((a, b) => b[1].ok + b[1].fail - (a[1].ok + a[1].fail))
|
|
264
|
+
.slice(0, 8)
|
|
265
|
+
.map(([tool, stats]) => ({
|
|
266
|
+
label: tool,
|
|
267
|
+
value: `${stats.ok} ok, ${stats.fail} fail`,
|
|
268
|
+
level: stats.fail > 0 ? ("warn" as const) : ("ok" as const),
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
const ruleRows: Row[] = Object.entries(rollup.shell.byRule ?? {})
|
|
272
|
+
.sort((a, b) => b[1] - a[1])
|
|
273
|
+
.map(([rule, count]) => ({ label: rule, value: String(count), level: "warn" as const }));
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
title: "session report",
|
|
277
|
+
summary: [`${rollup.provider}/${rollup.session_id}`, rollup.updated_at],
|
|
278
|
+
sections: [
|
|
279
|
+
{
|
|
280
|
+
title: "Cost",
|
|
281
|
+
rows: [
|
|
282
|
+
{ label: "estimated", value: costLabel, level: rollup.cost_incomplete ? "warn" : "info" },
|
|
283
|
+
{
|
|
284
|
+
// why: the reading is the transcript tail's total, not the session's. Labelled for what it is, because
|
|
285
|
+
// "Input tokens" invited the reading that produced 102.7M ([/decisions/ad-064.md](/decisions/ad-064.md)).
|
|
286
|
+
label: "tokens in/out",
|
|
287
|
+
value: `${rollup.input_tokens} / ${rollup.output_tokens} (latest reading, recent transcript only)`,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
title: "Activity",
|
|
293
|
+
rows: [
|
|
294
|
+
{ label: "prompts", value: String(rollup.prompts) },
|
|
295
|
+
{
|
|
296
|
+
label: "gates",
|
|
297
|
+
value: `${rollup.gates.pass} pass / ${rollup.gates.fail} fail`,
|
|
298
|
+
level: rollup.gates.fail > 0 ? "fail" : "ok",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
label: "policy denials",
|
|
302
|
+
value: String(rollup.denials),
|
|
303
|
+
level: rollup.denials > 0 ? "warn" : "ok",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
label: "shell",
|
|
307
|
+
value: `${shell.allow} allow / ${shell.ask} ask / ${shell.deny} deny`,
|
|
308
|
+
level: shell.deny > 0 ? "warn" : "ok",
|
|
309
|
+
},
|
|
310
|
+
{ label: "compactions", value: String(rollup.comped) },
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
...(ruleRows.length > 0 ? [{ title: "Interruptions by rule", rows: ruleRows }] : []),
|
|
314
|
+
...(toolRows.length > 0 ? [{ title: "Tools", rows: toolRows }] : []),
|
|
315
|
+
...(Object.keys(rollup.models).length > 0
|
|
316
|
+
? [{ title: "Models", lines: [top(rollup.models).join(" · ")] }]
|
|
317
|
+
: []),
|
|
318
|
+
],
|
|
319
|
+
footer:
|
|
320
|
+
"tlc harness why for the decisions · --json for the rollup · the markdown copy is under state/reports",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function sessionReportText(rollup: SessionRollup, style: Style = PLAIN): string {
|
|
325
|
+
return render(sessionReportScreen(rollup), style);
|
|
326
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { HarnessEvent } from "../../contracts/harness-event.ts";
|
|
3
|
+
import { appendAuditRecord, appendObsRecord, loadRollup, saveRollup } from "./observability.store.ts";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_OBS,
|
|
6
|
+
EVENT_KIND_TO_OBS_KIND,
|
|
7
|
+
type ObsEvent,
|
|
8
|
+
type ObservabilityConfig,
|
|
9
|
+
type ObsKind,
|
|
10
|
+
type ObsLevel,
|
|
11
|
+
redactDeep,
|
|
12
|
+
resolveObsLevel,
|
|
13
|
+
} from "./observability.types.ts";
|
|
14
|
+
|
|
15
|
+
export type RecordObsInput = {
|
|
16
|
+
provider: string;
|
|
17
|
+
kind: ObsKind;
|
|
18
|
+
sessionKey?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
level?: ObsLevel;
|
|
21
|
+
parentSpanId?: string;
|
|
22
|
+
attrs?: Record<string, unknown>;
|
|
23
|
+
gen_ai?: ObsEvent["gen_ai"];
|
|
24
|
+
forceDebug?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function shortId(): string {
|
|
28
|
+
return randomUUID().replace(/-/g, "").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deriveTraceId(sessionKey?: string): string {
|
|
32
|
+
const seed = sessionKey || randomUUID();
|
|
33
|
+
return createHash("sha256").update(seed).digest("hex").slice(0, 32);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateAttrs(attrs: Record<string, unknown>, max: number): Record<string, unknown> {
|
|
37
|
+
const out: Record<string, unknown> = {};
|
|
38
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
39
|
+
out[k] = typeof v === "string" && v.length > max ? `${v.slice(0, max)}\n…(truncated)` : v;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PAYLOAD_KEYS = new Set(["tool_input", "tool_output", "prompt", "text", "content", "output"]);
|
|
45
|
+
|
|
46
|
+
function stripPayloads(attrs: Record<string, unknown>): Record<string, unknown> {
|
|
47
|
+
const out: Record<string, unknown> = {};
|
|
48
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
49
|
+
if (!PAYLOAD_KEYS.has(k)) {
|
|
50
|
+
out[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function recordObs(root: string, config: ObservabilityConfig, input: RecordObsInput): ObsEvent | null {
|
|
57
|
+
if (!config.enabled) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const level = input.level ?? resolveObsLevel(input.kind, input.attrs ?? {}, !!input.forceDebug);
|
|
62
|
+
if (level === "debug" && !config.debugEnabled && !input.forceDebug) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let attrs = truncateAttrs(redactDeep(input.attrs ?? {}) as Record<string, unknown>, config.maxAttrChars);
|
|
67
|
+
if (!config.includePayloads) {
|
|
68
|
+
attrs = stripPayloads(attrs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const event: ObsEvent = {
|
|
72
|
+
schema: "harness.observability.v1",
|
|
73
|
+
provider: input.provider,
|
|
74
|
+
kind: input.kind,
|
|
75
|
+
level,
|
|
76
|
+
ts: new Date().toISOString(),
|
|
77
|
+
trace_id: deriveTraceId(input.sessionKey),
|
|
78
|
+
span_id: shortId(),
|
|
79
|
+
parent_span_id: input.parentSpanId,
|
|
80
|
+
session_id: input.sessionKey,
|
|
81
|
+
model: input.model,
|
|
82
|
+
attrs,
|
|
83
|
+
gen_ai: input.gen_ai,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const file = level === "signal" ? config.signalPath : config.debugPath;
|
|
87
|
+
if (!appendObsRecord(root, file, event, config.globalSpool)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (input.sessionKey) {
|
|
92
|
+
updateRollup(root, config, event);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return event;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function updateRollup(root: string, config: ObservabilityConfig, event: ObsEvent): void {
|
|
99
|
+
const sessionKey = event.session_id;
|
|
100
|
+
if (!sessionKey) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const rollup = loadRollup(root, sessionKey, event.provider);
|
|
104
|
+
|
|
105
|
+
if (event.model) {
|
|
106
|
+
rollup.models[event.model] = (rollup.models[event.model] ?? 0) + 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (event.kind === "tool.start" || event.kind === "tool.end" || event.kind === "tool.fail") {
|
|
110
|
+
const name = String(event.attrs.tool_name ?? "unknown");
|
|
111
|
+
const slot = rollup.tools[name] ?? { ok: 0, fail: 0, ms: 0 };
|
|
112
|
+
if (event.kind === "tool.end") {
|
|
113
|
+
slot.ok += 1;
|
|
114
|
+
slot.ms += Number(event.attrs.duration_ms ?? event.gen_ai?.duration_ms ?? 0);
|
|
115
|
+
}
|
|
116
|
+
if (event.kind === "tool.fail") {
|
|
117
|
+
slot.fail += 1;
|
|
118
|
+
}
|
|
119
|
+
rollup.tools[name] = slot;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (event.kind === "subagent.start") {
|
|
123
|
+
const type = String(event.attrs.subagent_type ?? "unknown");
|
|
124
|
+
const slot = rollup.subagents[type] ?? { count: 0, models: {} };
|
|
125
|
+
slot.count += 1;
|
|
126
|
+
const m = String(event.attrs.subagent_model ?? event.model ?? "unset");
|
|
127
|
+
slot.models[m] = (slot.models[m] ?? 0) + 1;
|
|
128
|
+
rollup.subagents[type] = slot;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (event.kind === "gate.outcome") {
|
|
132
|
+
const passed = Boolean(event.attrs.passed);
|
|
133
|
+
if (passed) {
|
|
134
|
+
rollup.gates.pass += 1;
|
|
135
|
+
} else {
|
|
136
|
+
rollup.gates.fail += 1;
|
|
137
|
+
}
|
|
138
|
+
// hazard: a rollup written by an older build has neither map, and incrementing into `undefined` would throw
|
|
139
|
+
// on the path that must never break a turn.
|
|
140
|
+
const name = String(event.attrs.gate ?? "unknown");
|
|
141
|
+
const byName = rollup.gatesByName ?? {};
|
|
142
|
+
const slot = byName[name] ?? { pass: 0, fail: 0 };
|
|
143
|
+
if (passed) {
|
|
144
|
+
slot.pass += 1;
|
|
145
|
+
} else {
|
|
146
|
+
slot.fail += 1;
|
|
147
|
+
}
|
|
148
|
+
byName[name] = slot;
|
|
149
|
+
rollup.gatesByName = byName;
|
|
150
|
+
|
|
151
|
+
const ms = Number(event.attrs.duration_ms ?? 0);
|
|
152
|
+
const timing = rollup.gateTime ?? {};
|
|
153
|
+
const cell = timing[name] ?? { runs: 0, totalMs: 0, worstMs: 0, reused: 0 };
|
|
154
|
+
// invariant: a reused verdict is counted apart and adds no time. Counting it as a run would divide the total
|
|
155
|
+
// by a larger number and make the command look faster than it is ([/decisions/ad-045.md](/decisions/ad-045.md)).
|
|
156
|
+
if (event.attrs.reused === true) {
|
|
157
|
+
cell.reused = (cell.reused ?? 0) + 1;
|
|
158
|
+
} else {
|
|
159
|
+
cell.runs += 1;
|
|
160
|
+
cell.totalMs += ms;
|
|
161
|
+
cell.worstMs = Math.max(cell.worstMs, ms);
|
|
162
|
+
}
|
|
163
|
+
timing[name] = cell;
|
|
164
|
+
rollup.gateTime = timing;
|
|
165
|
+
}
|
|
166
|
+
if (event.kind === "policy.deny") {
|
|
167
|
+
rollup.denials += 1;
|
|
168
|
+
}
|
|
169
|
+
if (event.kind === "session.start" && typeof event.attrs.injected_chars === "number") {
|
|
170
|
+
rollup.injected_chars = event.attrs.injected_chars;
|
|
171
|
+
}
|
|
172
|
+
if (event.kind === "session.start" && typeof event.attrs.durable_chars === "number") {
|
|
173
|
+
rollup.durable_chars = event.attrs.durable_chars;
|
|
174
|
+
}
|
|
175
|
+
if (event.kind === "session.start" && typeof event.attrs.hook_context_reliable === "boolean") {
|
|
176
|
+
rollup.hook_context_reliable = event.attrs.hook_context_reliable;
|
|
177
|
+
}
|
|
178
|
+
// invariant: one place counts refusals by rule, whichever rail produced them. Two counters for one fact is how
|
|
179
|
+
// they come to disagree.
|
|
180
|
+
if (event.kind === "policy.deny" || event.kind === "shell.start" || event.kind === "shell.end") {
|
|
181
|
+
const permission = String(event.attrs.permission ?? "");
|
|
182
|
+
if (permission === "ask" || permission === "deny") {
|
|
183
|
+
const rule = String(event.attrs.rule ?? "none");
|
|
184
|
+
const byRule = rollup.railsByRule ?? {};
|
|
185
|
+
byRule[rule] = (byRule[rule] ?? 0) + 1;
|
|
186
|
+
rollup.railsByRule = byRule;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (event.kind === "prompt.submit") {
|
|
190
|
+
rollup.prompts += 1;
|
|
191
|
+
}
|
|
192
|
+
if (event.kind === "agent.response") {
|
|
193
|
+
rollup.responses += 1;
|
|
194
|
+
}
|
|
195
|
+
if (event.kind === "agent.thought") {
|
|
196
|
+
rollup.thoughts += 1;
|
|
197
|
+
}
|
|
198
|
+
if (event.kind === "compact") {
|
|
199
|
+
rollup.comped += 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (event.kind === "shell.end" || event.kind === "shell.start") {
|
|
203
|
+
const perm = String(event.attrs.permission ?? "");
|
|
204
|
+
if (perm === "ask") {
|
|
205
|
+
rollup.shell.ask += 1;
|
|
206
|
+
} else if (perm === "deny") {
|
|
207
|
+
rollup.shell.deny += 1;
|
|
208
|
+
} else if (event.kind === "shell.end") {
|
|
209
|
+
rollup.shell.allow += 1;
|
|
210
|
+
}
|
|
211
|
+
// hazard: a rollup written by an older build has no `byRule`, and incrementing into `undefined` would throw
|
|
212
|
+
// inside the one path that must never break a turn.
|
|
213
|
+
if (perm === "ask" || perm === "deny") {
|
|
214
|
+
const rule = String(event.attrs.rule ?? "none");
|
|
215
|
+
const byRule = rollup.shell.byRule ?? {};
|
|
216
|
+
byRule[rule] = (byRule[rule] ?? 0) + 1;
|
|
217
|
+
rollup.shell.byRule = byRule;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (event.kind === "mcp.end" || event.kind === "mcp.start") {
|
|
222
|
+
const tool = String(event.attrs.tool_name ?? "unknown");
|
|
223
|
+
rollup.mcp[tool] = (rollup.mcp[tool] ?? 0) + 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* hazard: this added each reading to a running total, and the reading is not a delta. The transcript reader sums
|
|
228
|
+
* the tail of the transcript, so every value is a snapshot of a sliding window, and it is attached to every tool
|
|
229
|
+
* event. Summing 3,488 snapshots reported 102.7M output tokens against 559k input — a number nobody can act on
|
|
230
|
+
* ([/decisions/ad-064.md](/decisions/ad-064.md)).
|
|
231
|
+
*
|
|
232
|
+
* invariant: the latest reading replaces the previous one. A snapshot is assigned, never accumulated.
|
|
233
|
+
*/
|
|
234
|
+
const inTok = event.gen_ai?.input_tokens ?? 0;
|
|
235
|
+
const outTok = event.gen_ai?.output_tokens ?? 0;
|
|
236
|
+
if (inTok || outTok) {
|
|
237
|
+
rollup.input_tokens = inTok;
|
|
238
|
+
rollup.output_tokens = outTok;
|
|
239
|
+
const cost = event.gen_ai?.cost_usd;
|
|
240
|
+
if (typeof cost === "number") {
|
|
241
|
+
rollup.estimated_cost_usd = cost;
|
|
242
|
+
} else if (event.gen_ai?.cost_source === "missing") {
|
|
243
|
+
rollup.cost_incomplete = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
config.sessionCostAlertUsd != null &&
|
|
249
|
+
!rollup.cost_alert_sent &&
|
|
250
|
+
rollup.estimated_cost_usd >= config.sessionCostAlertUsd
|
|
251
|
+
) {
|
|
252
|
+
rollup.cost_alert_sent = true;
|
|
253
|
+
saveRollup(root, rollup);
|
|
254
|
+
recordObs(root, config, {
|
|
255
|
+
provider: event.provider,
|
|
256
|
+
kind: "cost.session_alert",
|
|
257
|
+
sessionKey,
|
|
258
|
+
attrs: {
|
|
259
|
+
session_cost_usd: rollup.estimated_cost_usd,
|
|
260
|
+
threshold_usd: config.sessionCostAlertUsd,
|
|
261
|
+
cost_incomplete: rollup.cost_incomplete,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
saveRollup(root, rollup);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function recordAudit(root: string, event: string, payload: unknown, spool = false): void {
|
|
271
|
+
appendAuditRecord(
|
|
272
|
+
root,
|
|
273
|
+
{
|
|
274
|
+
ts: new Date().toISOString(),
|
|
275
|
+
event,
|
|
276
|
+
payload: redactDeep(payload),
|
|
277
|
+
},
|
|
278
|
+
spool,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function recordFromEvent(
|
|
283
|
+
root: string,
|
|
284
|
+
config: ObservabilityConfig,
|
|
285
|
+
event: HarnessEvent,
|
|
286
|
+
extra: { gen_ai?: ObsEvent["gen_ai"]; forceDebug?: boolean } = {},
|
|
287
|
+
): ObsEvent | null {
|
|
288
|
+
const kind = EVENT_KIND_TO_OBS_KIND[event.event];
|
|
289
|
+
return recordObs(root, config, {
|
|
290
|
+
provider: event.provider,
|
|
291
|
+
kind,
|
|
292
|
+
sessionKey: event.sessionKey,
|
|
293
|
+
model: event.model,
|
|
294
|
+
forceDebug: extra.forceDebug,
|
|
295
|
+
gen_ai: extra.gen_ai,
|
|
296
|
+
attrs: {
|
|
297
|
+
tool_name: event.toolName,
|
|
298
|
+
command: event.command,
|
|
299
|
+
file_path: event.filePath,
|
|
300
|
+
subagent_type: event.subagentType,
|
|
301
|
+
status: event.status,
|
|
302
|
+
context_usage_percent: event.contextUsagePercent,
|
|
303
|
+
text_chars: typeof event.text === "string" ? event.text.length : undefined,
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export { DEFAULT_OBS };
|
|
309
|
+
|
|
310
|
+
// invariant: what `recordRollup` branches on. Fed inline at record time, so it sees every event before plane
|
|
311
|
+
// routing — which is why the contract marks it `inline` rather than naming planes.
|
|
312
|
+
export const ROLLUP_KINDS = [
|
|
313
|
+
"agent.response",
|
|
314
|
+
"agent.thought",
|
|
315
|
+
"compact",
|
|
316
|
+
"gate.outcome",
|
|
317
|
+
"mcp.end",
|
|
318
|
+
"mcp.start",
|
|
319
|
+
"policy.deny",
|
|
320
|
+
"prompt.submit",
|
|
321
|
+
"session.start",
|
|
322
|
+
"shell.end",
|
|
323
|
+
"shell.start",
|
|
324
|
+
"subagent.start",
|
|
325
|
+
"tool.end",
|
|
326
|
+
"tool.fail",
|
|
327
|
+
"tool.start",
|
|
328
|
+
] as const;
|