@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,269 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { appendRecord, readTail } from "../../platform/fs-jsonl.ts";
|
|
4
|
+
import { projectStateDir, runtimeSpoolPath, runtimeStateDir } from "../../platform/paths.ts";
|
|
5
|
+
import { sanitizeSegment } from "../../platform/sanitize.ts";
|
|
6
|
+
import type { AuditRecord, ObsEvent } from "./observability.types.ts";
|
|
7
|
+
|
|
8
|
+
export type SessionRollup = {
|
|
9
|
+
session_id: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
started_at: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
models: Record<string, number>;
|
|
14
|
+
tools: Record<string, { ok: number; fail: number; ms: number }>;
|
|
15
|
+
subagents: Record<string, { count: number; models: Record<string, number> }>;
|
|
16
|
+
gates: { pass: number; fail: number };
|
|
17
|
+
denials: number;
|
|
18
|
+
prompts: number;
|
|
19
|
+
responses: number;
|
|
20
|
+
thoughts: number;
|
|
21
|
+
comped: number;
|
|
22
|
+
/**
|
|
23
|
+
* why: `byRule` exists because a count on its own is not actionable. "Seven interruptions" tells an operator
|
|
24
|
+
* nothing about which switch to reach for; "six from the paired posture, one from the catastrophic rule" does.
|
|
25
|
+
*/
|
|
26
|
+
shell: { allow: number; ask: number; deny: number; byRule: Record<string, number> };
|
|
27
|
+
/**
|
|
28
|
+
* why: every refusal the harness made, shell and otherwise, keyed by the rule that made it. This is what turns
|
|
29
|
+
* "the harness interrupted me a lot" into "this rule interrupted you a lot", which is the only form an operator
|
|
30
|
+
* can act on ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
31
|
+
*/
|
|
32
|
+
railsByRule: Record<string, number>;
|
|
33
|
+
/** Per-gate pass/fail, so a single flaky gate is distinguishable from a codebase that does not build. */
|
|
34
|
+
gatesByName: Record<string, { pass: number; fail: number }>;
|
|
35
|
+
/**
|
|
36
|
+
* why: an operator reported thirty minutes and had no way to see where it went. The duration was already recorded
|
|
37
|
+
* on every gate outcome and surfaced nowhere. `worstMs` separates "one slow run" from "many runs"
|
|
38
|
+
* ([/decisions/ad-033.md](/decisions/ad-033.md)).
|
|
39
|
+
*/
|
|
40
|
+
gateTime: Record<string, { runs: number; totalMs: number; worstMs: number; reused?: number }>;
|
|
41
|
+
/** Characters of prose the harness injected at session start. The cost side of every rail, in one number. */
|
|
42
|
+
injected_chars: number;
|
|
43
|
+
/**
|
|
44
|
+
* hazard: `injected_chars` was reported as the price paid on every turn. On a host that drops hook context it is
|
|
45
|
+
* paid never, and the durable rules file — which asks to be included on every request — was absent from the
|
|
46
|
+
* rollup. The number was wrong in both directions there ([/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
47
|
+
*/
|
|
48
|
+
durable_chars: number;
|
|
49
|
+
/** What the provider declares about delivering context from its session-start hook. */
|
|
50
|
+
hook_context_reliable: boolean;
|
|
51
|
+
mcp: Record<string, number>;
|
|
52
|
+
estimated_cost_usd: number;
|
|
53
|
+
cost_incomplete: boolean;
|
|
54
|
+
input_tokens: number;
|
|
55
|
+
output_tokens: number;
|
|
56
|
+
cost_alert_sent: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function safeMkdir(dir: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SpoolStream = "obs" | "audit";
|
|
69
|
+
|
|
70
|
+
export type SpoolEnvelope = {
|
|
71
|
+
repo: string;
|
|
72
|
+
project: string;
|
|
73
|
+
stream: SpoolStream;
|
|
74
|
+
record: unknown;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function spoolEnvelope(root: string, stream: SpoolStream, record: unknown): SpoolEnvelope {
|
|
78
|
+
return { repo: root, project: basename(root), stream, record };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// hazard: best-effort by contract. The project's own state directory holds the authoritative copy, so a
|
|
82
|
+
// read-only runtime home, a full disk or a concurrent writer must degrade to project-only recording
|
|
83
|
+
// rather than change what the caller is told.
|
|
84
|
+
export function appendSpoolRecord(root: string, stream: SpoolStream, record: unknown): boolean {
|
|
85
|
+
try {
|
|
86
|
+
if (!safeMkdir(runtimeStateDir())) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
appendRecord(runtimeSpoolPath(), spoolEnvelope(root, stream, record));
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function appendObsRecord(root: string, file: string, event: ObsEvent, spool = false): boolean {
|
|
97
|
+
if (!safeMkdir(projectStateDir(root))) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
appendRecord(join(projectStateDir(root), file), event);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (spool) {
|
|
106
|
+
appendSpoolRecord(root, "obs", event);
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function appendAuditRecord(root: string, record: AuditRecord, spool = false): boolean {
|
|
112
|
+
if (!safeMkdir(projectStateDir(root))) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
appendRecord(join(projectStateDir(root), "audit.jsonl"), record);
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (spool) {
|
|
121
|
+
appendSpoolRecord(root, "audit", record);
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function spoolLineTimestamp(line: string): number | null {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(line) as SpoolEnvelope;
|
|
129
|
+
const record = parsed.record as { ts?: unknown } | null;
|
|
130
|
+
const ts = typeof record?.ts === "string" ? Date.parse(record.ts) : Number.NaN;
|
|
131
|
+
return Number.isNaN(ts) ? null : ts;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// hazard: rewriting a file every repository on the machine appends to can drop a line written between the
|
|
138
|
+
// read and the rename. That is acceptable here and only here: the spool is a derived cross-repo view, and
|
|
139
|
+
// the per-project records it mirrors are never touched.
|
|
140
|
+
export function pruneSpool(retentionDays: number, now = Date.now()): number {
|
|
141
|
+
const path = runtimeSpoolPath();
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
146
|
+
let lines: string[] = [];
|
|
147
|
+
try {
|
|
148
|
+
lines = readFileSync(path, "utf8")
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter((line) => line.trim().length > 0);
|
|
151
|
+
} catch {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const kept = lines.filter((line) => {
|
|
155
|
+
const ts = spoolLineTimestamp(line);
|
|
156
|
+
return ts === null || ts >= cutoff;
|
|
157
|
+
});
|
|
158
|
+
if (kept.length === lines.length) {
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(path, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
return lines.length - kept.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function readSignalEvents(root: string, file: string, limit = 200): ObsEvent[] {
|
|
170
|
+
try {
|
|
171
|
+
return readTail<ObsEvent>(join(projectStateDir(root), file), limit);
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rollupPath(root: string, sessionKey: string): string {
|
|
178
|
+
return join(projectStateDir(root), "sessions", `${sanitizeSegment(sessionKey)}.json`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readJson<T>(path: string): T | null {
|
|
182
|
+
if (!existsSync(path)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function newRollup(sessionKey: string, provider: string): SessionRollup {
|
|
193
|
+
const now = new Date().toISOString();
|
|
194
|
+
return {
|
|
195
|
+
session_id: sessionKey,
|
|
196
|
+
provider,
|
|
197
|
+
started_at: now,
|
|
198
|
+
updated_at: now,
|
|
199
|
+
models: {},
|
|
200
|
+
tools: {},
|
|
201
|
+
subagents: {},
|
|
202
|
+
gates: { pass: 0, fail: 0 },
|
|
203
|
+
denials: 0,
|
|
204
|
+
prompts: 0,
|
|
205
|
+
responses: 0,
|
|
206
|
+
thoughts: 0,
|
|
207
|
+
comped: 0,
|
|
208
|
+
shell: { allow: 0, ask: 0, deny: 0, byRule: {} },
|
|
209
|
+
railsByRule: {},
|
|
210
|
+
gatesByName: {},
|
|
211
|
+
gateTime: {},
|
|
212
|
+
injected_chars: 0,
|
|
213
|
+
durable_chars: 0,
|
|
214
|
+
// why: the pessimistic default. A rollup whose session start never ran claims no delivery rather than claiming
|
|
215
|
+
// one it did not observe.
|
|
216
|
+
hook_context_reliable: false,
|
|
217
|
+
mcp: {},
|
|
218
|
+
estimated_cost_usd: 0,
|
|
219
|
+
cost_incomplete: false,
|
|
220
|
+
input_tokens: 0,
|
|
221
|
+
output_tokens: 0,
|
|
222
|
+
cost_alert_sent: false,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function loadRollup(root: string, sessionKey: string, provider: string): SessionRollup {
|
|
227
|
+
return readJson<SessionRollup>(rollupPath(root, sessionKey)) ?? newRollup(sessionKey, provider);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function saveRollup(root: string, rollup: SessionRollup): boolean {
|
|
231
|
+
const dir = join(projectStateDir(root), "sessions");
|
|
232
|
+
if (!safeMkdir(dir)) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
rollup.updated_at = new Date().toISOString();
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(rollupPath(root, rollup.session_id), `${JSON.stringify(rollup, null, 2)}\n`, "utf8");
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getRollup(root: string, sessionKey: string): SessionRollup | null {
|
|
245
|
+
return readJson<SessionRollup>(rollupPath(root, sessionKey));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function pruneObs(root: string, retentionDays: number): void {
|
|
249
|
+
const dir = join(projectStateDir(root), "sessions");
|
|
250
|
+
if (!existsSync(dir)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
254
|
+
let names: string[] = [];
|
|
255
|
+
try {
|
|
256
|
+
names = readdirSync(dir);
|
|
257
|
+
} catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
const full = join(dir, name);
|
|
262
|
+
const data = readJson<SessionRollup>(full);
|
|
263
|
+
if (data && Date.parse(data.updated_at) < cutoff) {
|
|
264
|
+
try {
|
|
265
|
+
unlinkSync(full);
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { HarnessEventKind } from "../../contracts/harness-event.ts";
|
|
2
|
+
|
|
3
|
+
export type ObsLevel = "signal" | "debug";
|
|
4
|
+
|
|
5
|
+
export type ObsKind =
|
|
6
|
+
| "session.start"
|
|
7
|
+
| "session.end"
|
|
8
|
+
| "generation.start"
|
|
9
|
+
| "generation.end"
|
|
10
|
+
| "tool.start"
|
|
11
|
+
| "tool.end"
|
|
12
|
+
| "tool.fail"
|
|
13
|
+
| "shell.start"
|
|
14
|
+
| "shell.end"
|
|
15
|
+
| "mcp.start"
|
|
16
|
+
| "mcp.end"
|
|
17
|
+
| "subagent.start"
|
|
18
|
+
| "subagent.end"
|
|
19
|
+
| "file.edit"
|
|
20
|
+
| "file.read"
|
|
21
|
+
| "prompt.submit"
|
|
22
|
+
| "agent.response"
|
|
23
|
+
| "agent.thought"
|
|
24
|
+
| "compact"
|
|
25
|
+
| "gate.outcome"
|
|
26
|
+
| "cost.turn"
|
|
27
|
+
| "cost.session_alert"
|
|
28
|
+
| "ship.claim"
|
|
29
|
+
| "policy.deny"
|
|
30
|
+
// why: an observation is not a refusal. Sharing the kind would inflate the denial count with readings the
|
|
31
|
+
// operator never lived through, which is the opposite of what the rail exists for.
|
|
32
|
+
| "policy.observe";
|
|
33
|
+
|
|
34
|
+
export type CostPool = "provider_native" | "other" | "auto" | "unknown";
|
|
35
|
+
|
|
36
|
+
export type CostSource = "provider" | "litellm" | "override" | "billed" | "missing";
|
|
37
|
+
|
|
38
|
+
export type ObsEvent = {
|
|
39
|
+
schema: "harness.observability.v1";
|
|
40
|
+
provider: string;
|
|
41
|
+
kind: ObsKind;
|
|
42
|
+
level: ObsLevel;
|
|
43
|
+
ts: string;
|
|
44
|
+
trace_id: string;
|
|
45
|
+
span_id: string;
|
|
46
|
+
parent_span_id?: string;
|
|
47
|
+
session_id?: string;
|
|
48
|
+
model?: string;
|
|
49
|
+
attrs: Record<string, unknown>;
|
|
50
|
+
// invariant: every field here is written by `usageGenAi`. `operation_name`, `provider_name`, `request_model`,
|
|
51
|
+
// `reasoning_tokens` and `outcome` were declared on the shipped schema and populated by nothing, which is the
|
|
52
|
+
// same defect as a consumer without a producer pointed the other way ([/decisions/ad-041.md](/decisions/ad-041.md)).
|
|
53
|
+
gen_ai?: {
|
|
54
|
+
input_tokens?: number;
|
|
55
|
+
output_tokens?: number;
|
|
56
|
+
cache_read_tokens?: number;
|
|
57
|
+
cache_write_tokens?: number;
|
|
58
|
+
cost_usd?: number | null;
|
|
59
|
+
cost_source?: CostSource;
|
|
60
|
+
cost_pool?: CostPool;
|
|
61
|
+
duration_ms?: number;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type AuditRecord = {
|
|
66
|
+
ts: string;
|
|
67
|
+
event: string;
|
|
68
|
+
payload: unknown;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ObservabilityConfig = {
|
|
72
|
+
enabled: boolean;
|
|
73
|
+
signalPath: "obs.jsonl";
|
|
74
|
+
debugPath: "debug.jsonl";
|
|
75
|
+
debugEnabled: boolean;
|
|
76
|
+
includePayloads: boolean;
|
|
77
|
+
maxAttrChars: number;
|
|
78
|
+
sessionCostAlertUsd: number | null;
|
|
79
|
+
retentionDays: number;
|
|
80
|
+
maxSignalEvents: number;
|
|
81
|
+
globalSpool: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_OBS: ObservabilityConfig = {
|
|
85
|
+
enabled: true,
|
|
86
|
+
signalPath: "obs.jsonl",
|
|
87
|
+
debugPath: "debug.jsonl",
|
|
88
|
+
debugEnabled: false,
|
|
89
|
+
includePayloads: false,
|
|
90
|
+
maxAttrChars: 500,
|
|
91
|
+
sessionCostAlertUsd: 5,
|
|
92
|
+
retentionDays: 14,
|
|
93
|
+
maxSignalEvents: 50_000,
|
|
94
|
+
globalSpool: false,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const SIGNAL_KINDS = new Set<ObsKind>([
|
|
98
|
+
"session.start",
|
|
99
|
+
"session.end",
|
|
100
|
+
"generation.end",
|
|
101
|
+
"tool.fail",
|
|
102
|
+
"subagent.start",
|
|
103
|
+
"subagent.end",
|
|
104
|
+
"prompt.submit",
|
|
105
|
+
"compact",
|
|
106
|
+
"gate.outcome",
|
|
107
|
+
"cost.turn",
|
|
108
|
+
"cost.session_alert",
|
|
109
|
+
"ship.claim",
|
|
110
|
+
"policy.deny",
|
|
111
|
+
"policy.observe",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
export const LIVE_ALLOWLIST = new Set<ObsKind>([
|
|
115
|
+
"session.start",
|
|
116
|
+
"session.end",
|
|
117
|
+
"generation.end",
|
|
118
|
+
"tool.fail",
|
|
119
|
+
"shell.end",
|
|
120
|
+
"subagent.start",
|
|
121
|
+
"subagent.end",
|
|
122
|
+
"gate.outcome",
|
|
123
|
+
"cost.turn",
|
|
124
|
+
"cost.session_alert",
|
|
125
|
+
"ship.claim",
|
|
126
|
+
"policy.deny",
|
|
127
|
+
"compact",
|
|
128
|
+
"prompt.submit",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
export function resolveObsLevel(
|
|
132
|
+
kind: ObsKind,
|
|
133
|
+
attrs: Record<string, unknown> = {},
|
|
134
|
+
forceDebug = false,
|
|
135
|
+
): ObsLevel {
|
|
136
|
+
if (forceDebug) {
|
|
137
|
+
return "debug";
|
|
138
|
+
}
|
|
139
|
+
// hazard: this branch used to key on `shell.end` alone, and a command that was denied or asked about never
|
|
140
|
+
// reaches an "after" event — so the only permission it could ever grade was `allow`, and `rollup.shell.ask`
|
|
141
|
+
// could not have moved even once the attribute was written. The decision is made at `shell.start`; that is the
|
|
142
|
+
// only phase where a non-allow permission exists ([/decisions/ad-026.md](/decisions/ad-026.md)).
|
|
143
|
+
if (kind === "shell.end" || kind === "shell.start") {
|
|
144
|
+
const permission = String(attrs.permission ?? "allow");
|
|
145
|
+
return permission === "allow" ? "debug" : "signal";
|
|
146
|
+
}
|
|
147
|
+
if (kind === "mcp.end") {
|
|
148
|
+
const outcome = String(attrs.outcome ?? attrs.status ?? "success");
|
|
149
|
+
return outcome === "error" || outcome === "fail" || outcome === "denied" ? "signal" : "debug";
|
|
150
|
+
}
|
|
151
|
+
return SIGNAL_KINDS.has(kind) ? "signal" : "debug";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const EVENT_KIND_TO_OBS_KIND: Record<HarnessEventKind, ObsKind> = {
|
|
155
|
+
"session.start": "session.start",
|
|
156
|
+
"session.end": "session.end",
|
|
157
|
+
"prompt.submit": "prompt.submit",
|
|
158
|
+
"tool.before": "tool.start",
|
|
159
|
+
"tool.after": "tool.end",
|
|
160
|
+
"tool.failure": "tool.fail",
|
|
161
|
+
"shell.before": "shell.start",
|
|
162
|
+
"shell.after": "shell.end",
|
|
163
|
+
"mcp.before": "mcp.start",
|
|
164
|
+
"mcp.after": "mcp.end",
|
|
165
|
+
"read.before": "file.read",
|
|
166
|
+
"edit.after": "file.edit",
|
|
167
|
+
"subagent.start": "subagent.start",
|
|
168
|
+
"subagent.stop": "subagent.end",
|
|
169
|
+
stop: "generation.end",
|
|
170
|
+
"compact.before": "compact",
|
|
171
|
+
"response.after": "agent.response",
|
|
172
|
+
"thought.after": "agent.thought",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const SECRET_KEY = /(token|secret|password|api[_-]?key|authorization|credential|private[_-]?key)/i;
|
|
176
|
+
const SECRET_VALUE = /\b(ghp_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
|
|
177
|
+
|
|
178
|
+
export function redactDeep(value: unknown): unknown {
|
|
179
|
+
if (typeof value === "string") {
|
|
180
|
+
return value.replace(SECRET_VALUE, "[REDACTED]");
|
|
181
|
+
}
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return value.map(redactDeep);
|
|
184
|
+
}
|
|
185
|
+
if (value && typeof value === "object") {
|
|
186
|
+
const out: Record<string, unknown> = {};
|
|
187
|
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
188
|
+
out[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redactDeep(nested);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { type ColorName, PLAIN, type Style, SYMBOLS } from "../../platform/style.ts";
|
|
2
|
+
import type { ObsEvent } from "./observability.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* why: an operator watching an agent cannot tell a harness decision from model behaviour, because a hook answers
|
|
6
|
+
* the host and the host decides whether to show it. `obs report` is a session rollup and `obs live` is a firehose;
|
|
7
|
+
* neither answers "did the harness just do that?" ([/decisions/ad-062.md](/decisions/ad-062.md)).
|
|
8
|
+
*
|
|
9
|
+
* invariant: derived from the two obs planes and nothing else. A second producer would be a second index, and the
|
|
10
|
+
* planes are already the record of record.
|
|
11
|
+
*/
|
|
12
|
+
export type HarnessDecision = {
|
|
13
|
+
ts: string;
|
|
14
|
+
/** What the harness was answering — the event kind, or the gate's name. */
|
|
15
|
+
about: string;
|
|
16
|
+
/** What it did: deny, ask, allow, pass, fail, or context. */
|
|
17
|
+
verdict: string;
|
|
18
|
+
/** The rule responsible, or `null` when the record carries none. */
|
|
19
|
+
rule: string | null;
|
|
20
|
+
/** One line of what it was about — a command, a tool, a size. Never the model's words. */
|
|
21
|
+
detail: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const NONE = new Set(["none", "", "undefined"]);
|
|
25
|
+
|
|
26
|
+
function attr(event: ObsEvent, name: string): string | undefined {
|
|
27
|
+
const value = (event.attrs as Record<string, unknown> | undefined)?.[name];
|
|
28
|
+
return value === undefined || value === null ? undefined : String(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ruleOf(event: ObsEvent): string | null {
|
|
32
|
+
const raw = attr(event, "rule");
|
|
33
|
+
return raw === undefined || NONE.has(raw) ? null : raw;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncate(text: string | undefined, max = 90): string {
|
|
37
|
+
if (!text) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
41
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* invariant: only records that represent a decision. Everything else in the planes is activity — a turn doing
|
|
46
|
+
* work is not the harness doing something, and listing it would bury the four lines that matter.
|
|
47
|
+
*/
|
|
48
|
+
export function decisionsFrom(events: readonly ObsEvent[], sessionKey?: string): HarnessDecision[] {
|
|
49
|
+
const out: HarnessDecision[] = [];
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
if (sessionKey !== undefined && event.session_id !== sessionKey) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (event.kind === "policy.deny") {
|
|
55
|
+
out.push({
|
|
56
|
+
ts: event.ts,
|
|
57
|
+
about: attr(event, "event") ?? "tool",
|
|
58
|
+
verdict: attr(event, "permission") ?? "deny",
|
|
59
|
+
rule: ruleOf(event),
|
|
60
|
+
detail: truncate(attr(event, "tool_name")),
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (event.kind === "shell.start") {
|
|
65
|
+
out.push({
|
|
66
|
+
ts: event.ts,
|
|
67
|
+
about: "shell",
|
|
68
|
+
verdict: attr(event, "permission") ?? "allow",
|
|
69
|
+
rule: ruleOf(event),
|
|
70
|
+
detail: truncate(attr(event, "command")),
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (event.kind === "gate.outcome") {
|
|
75
|
+
out.push({
|
|
76
|
+
ts: event.ts,
|
|
77
|
+
about: `gate ${attr(event, "gate") ?? "?"}`,
|
|
78
|
+
verdict: attr(event, "passed") === "true" ? "pass" : "fail",
|
|
79
|
+
rule: null,
|
|
80
|
+
detail: truncate(attr(event, "scoped_env") === "none" ? "" : `env: ${attr(event, "scoped_env")}`),
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// hazard: observation mode exists to produce a reading an operator acts on, and no command showed one. The
|
|
85
|
+
// orphan report found it on its first run ([/decisions/ad-065.md](/decisions/ad-065.md)).
|
|
86
|
+
if (event.kind === "policy.observe") {
|
|
87
|
+
out.push({
|
|
88
|
+
ts: event.ts,
|
|
89
|
+
about: `observe ${attr(event, "rail") ?? attr(event, "rule") ?? "?"}`,
|
|
90
|
+
verdict: attr(event, "reading") ?? "observed",
|
|
91
|
+
rule: ruleOf(event),
|
|
92
|
+
detail: truncate(attr(event, "violations") ? `${attr(event, "violations")} violation(s)` : ""),
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (event.kind === "cost.session_alert") {
|
|
97
|
+
out.push({
|
|
98
|
+
ts: event.ts,
|
|
99
|
+
about: "cost alert",
|
|
100
|
+
verdict: "alert",
|
|
101
|
+
rule: null,
|
|
102
|
+
detail: truncate(`$${attr(event, "estimated_cost_usd") ?? "?"} passed the session threshold`),
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (event.kind === "session.start") {
|
|
107
|
+
out.push({
|
|
108
|
+
ts: event.ts,
|
|
109
|
+
about: "session start",
|
|
110
|
+
verdict: "context",
|
|
111
|
+
rule: null,
|
|
112
|
+
detail: truncate(`${attr(event, "injected_chars") ?? "0"} chars injected`),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out.sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? 1 : -1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const NOTHING_WAS_THE_HARNESS = [
|
|
120
|
+
"No harness decision in this window.",
|
|
121
|
+
"Whatever you just saw was the model, not a rail — the harness allowed everything it was asked about.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
|
|
124
|
+
export type WhySummary = { denied: number; asked: number; allowed: number; other: number };
|
|
125
|
+
|
|
126
|
+
export function summarise(decisions: readonly HarnessDecision[]): WhySummary {
|
|
127
|
+
const tally = { denied: 0, asked: 0, allowed: 0, other: 0 };
|
|
128
|
+
for (const decision of decisions) {
|
|
129
|
+
if (decision.verdict === "deny") {
|
|
130
|
+
tally.denied += 1;
|
|
131
|
+
} else if (decision.verdict === "ask") {
|
|
132
|
+
tally.asked += 1;
|
|
133
|
+
} else if (decision.verdict === "allow") {
|
|
134
|
+
tally.allowed += 1;
|
|
135
|
+
} else {
|
|
136
|
+
tally.other += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return tally;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* why: the empty case is the feature. "Nothing here was the harness" is the sentence an operator cannot get from
|
|
144
|
+
* any other command, and printing an empty table instead would leave them exactly as unsure as before.
|
|
145
|
+
*/
|
|
146
|
+
export function whyText(
|
|
147
|
+
decisions: readonly HarnessDecision[],
|
|
148
|
+
style: Style = PLAIN,
|
|
149
|
+
now = new Date(),
|
|
150
|
+
): string {
|
|
151
|
+
if (decisions.length === 0) {
|
|
152
|
+
return [
|
|
153
|
+
style.paint("success", `${SYMBOLS.check} ${NOTHING_WAS_THE_HARNESS.split("\n")[0]}`),
|
|
154
|
+
style.dim(NOTHING_WAS_THE_HARNESS.split("\n")[1] ?? ""),
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tally = summarise(decisions);
|
|
159
|
+
const today = now.toISOString().slice(0, 10);
|
|
160
|
+
const parts = [
|
|
161
|
+
tally.denied > 0 ? style.paint("error", `${tally.denied} denied`) : "",
|
|
162
|
+
tally.asked > 0 ? style.paint("warning", `${tally.asked} asked`) : "",
|
|
163
|
+
tally.allowed > 0 ? style.paint("success", `${tally.allowed} allowed`) : "",
|
|
164
|
+
tally.other > 0 ? style.dim(`${tally.other} other`) : "",
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
|
|
167
|
+
const lines = decisions.map((decision) => {
|
|
168
|
+
// why: the date appears only when the record is not from today. Ten identical timestamps with no date was
|
|
169
|
+
// the reading that made the first version unreadable — it looked like one burst and spanned three days.
|
|
170
|
+
const day = decision.ts.slice(0, 10);
|
|
171
|
+
const when = day === today ? decision.ts.slice(11, 19) : `${day} ${decision.ts.slice(11, 19)}`;
|
|
172
|
+
const verdictColor: ColorName =
|
|
173
|
+
decision.verdict === "deny" ? "error" : decision.verdict === "ask" ? "warning" : "success";
|
|
174
|
+
// why: the `rule=` prefix survives because it is what every denial message prints and what the
|
|
175
|
+
// troubleshooting page tells people to grep for. Colour is not available when the output is piped.
|
|
176
|
+
const rule =
|
|
177
|
+
decision.rule === null ? style.dim("rule=unattributed") : style.paint("info", `rule=${decision.rule}`);
|
|
178
|
+
const head = [
|
|
179
|
+
style.dim(when.padEnd(day === today ? 8 : 19)),
|
|
180
|
+
style.paint("textMuted", decision.about.padEnd(14)),
|
|
181
|
+
style.paint(verdictColor, decision.verdict.padEnd(7)),
|
|
182
|
+
rule,
|
|
183
|
+
].join(" ");
|
|
184
|
+
return decision.detail ? `${head}\n${" ".repeat(4)}${style.dim(SYMBOLS.bar)} ${decision.detail}` : head;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return [
|
|
188
|
+
style.heading(`LAST ${decisions.length} HARNESS DECISION${decisions.length === 1 ? "" : "S"}`),
|
|
189
|
+
` ${parts.join(style.dim(` ${SYMBOLS.bar} `))}`,
|
|
190
|
+
"",
|
|
191
|
+
...lines,
|
|
192
|
+
"",
|
|
193
|
+
style.footer("newest first · tlc harness why 30 widens the window · --json for the records"),
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// invariant: what `decisionsFrom` branches on, declared so `check-obs-contract` can verify both sides. A kind
|
|
198
|
+
// added to the switch and not to this set is a consumer the contract cannot see.
|
|
199
|
+
export const WHY_KINDS = [
|
|
200
|
+
"policy.deny",
|
|
201
|
+
"shell.start",
|
|
202
|
+
"gate.outcome",
|
|
203
|
+
"session.start",
|
|
204
|
+
"policy.observe",
|
|
205
|
+
"cost.session_alert",
|
|
206
|
+
] as const;
|