@polydeukes/core 0.3.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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @polydeukes/core — the thin, domain- and agent-agnostic core.
3
+ *
4
+ * Pre-alpha. The covenant protocol (CORE-01) landed first, then the ROI telemetry
5
+ * collector (CORE-02) and the config loader (CONFIG-01). Pure types and functions,
6
+ * except telemetry's confined I/O functions (appendRecord / readRecords /
7
+ * appendRecordFailOpen — the fail-open wrapper promoted by CORE-05).
8
+ * See https://github.com/huskyhoochu/polydeukes
9
+ */
10
+ export { ConfigValidationError, DEFAULT_TELEMETRY_LOG_PATH, type DisciplineEntry, type DisciplineForbid, defineConfig, type LanguageProfile, type PolydeukesConfig, type ResolvedConfig, type ResolvedLanguageProfile, } from './config.js';
11
+ export { type FailMode, type FailureKind, failModeToExitCode, resolveFailMode, } from './fail-policy.js';
12
+ export { isPlainObject } from './is-plain-object.js';
13
+ export { normalizeProtectedPaths } from './protected-paths.js';
14
+ export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, type GainSummary, parseRecordLine, readRecords, runGain, type TelemetryEvent, type TelemetryRecord, } from './telemetry.js';
15
+ export { type CanonicalTranscript, noopTranscript, type SubagentInvocation, type TranscriptToolCall, type TranscriptUserMessage, transcriptFromInput, } from './transcript.js';
16
+ /**
17
+ * exit-code semantics of the covenant protocol (PRD §4.1).
18
+ *
19
+ * The three codes are distinct and ordered by severity. The covenant *body* only
20
+ * ever emits `0` (uphold) or `1` (break, non-blocking); translating a break into the
21
+ * blocking `2` is the wrapper's job (COVENANT-01), never the core's. The sole place
22
+ * the core itself reaches for `2` is the fail-closed parse path below.
23
+ */
24
+ /** Promise upheld — no violation, the edit/push passes. */
25
+ export declare const EXIT_UPHOLD = 0;
26
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
27
+ export declare const EXIT_BREAK_NON_BLOCKING = 1;
28
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
29
+ export declare const EXIT_BREAK_BLOCKING = 2;
30
+ /**
31
+ * `FileChange` — one file's mutation evidence around the judged call (CORE-06 §4.1).
32
+ *
33
+ * Agent-neutral, discriminated by `kind`: a deletion is first-class evidence rather
34
+ * than an unrepresentable case, and impossible states (a deletion with resulting
35
+ * content, a creation with a baseline) cannot be written down. Adapters fill this from
36
+ * their own sources (virtual apply, git blobs) — the core only transports it.
37
+ * `delete.pre` is the readable text baseline when one exists — absent for a binary
38
+ * blob, because a deletion needs no content to be judged.
39
+ */
40
+ export type FileChange = {
41
+ kind: 'create';
42
+ path: string;
43
+ post: string;
44
+ } | {
45
+ kind: 'modify';
46
+ path: string;
47
+ pre: string;
48
+ post: string;
49
+ } | {
50
+ kind: 'delete';
51
+ path: string;
52
+ pre?: string;
53
+ };
54
+ /**
55
+ * `CovenantInput` — the agent-neutral input IR a covenant judges (PRD §4.2).
56
+ *
57
+ * Adapters up-translate their own agent payloads into this shape and pipe it as
58
+ * stdin-JSON. The vocabulary carries no agent/tool literals; concrete tool or
59
+ * subagent names are *values* an adapter fills in, never part of the core's type.
60
+ * Evidence has exactly one home — the call element it belongs to (CORE-06 §4.1):
61
+ * `fileChange` absent means "this call is unproven", and no sibling call's evidence
62
+ * can stand in for it.
63
+ */
64
+ export type CovenantInput = {
65
+ toolCalls: {
66
+ name: string;
67
+ args?: Record<string, unknown>;
68
+ fileChange?: FileChange;
69
+ }[];
70
+ subagentSpawns: {
71
+ kind: string;
72
+ }[];
73
+ userMessages: {
74
+ text: string;
75
+ }[];
76
+ };
77
+ /**
78
+ * `CovenantVerdict` — the result a covenant body produces (PRD §4.3).
79
+ *
80
+ * Either the promise was upheld, or it was broken with a human-readable reason.
81
+ * Maps to an exit code via {@link verdictToExitCode}.
82
+ */
83
+ export type CovenantVerdict = {
84
+ upheld: true;
85
+ } | {
86
+ upheld: false;
87
+ reason: string;
88
+ };
89
+ /**
90
+ * Deserialize stdin-JSON into a {@link CovenantInput} (the protocol's reverse direction).
91
+ *
92
+ * fail-closed (PRD §5.2): this never throws. Any failure — unparseable JSON, an empty
93
+ * payload, a parsed value that is not an object, or a missing required collection —
94
+ * resolves to a blocking `{ ok: false, exitCode: 2 }`. "Cannot judge" means block,
95
+ * so an unjudgeable input can never be mistaken for a valid one.
96
+ */
97
+ export declare function parseInput(stdinJson: string): {
98
+ ok: true;
99
+ value: CovenantInput;
100
+ } | {
101
+ ok: false;
102
+ exitCode: 2;
103
+ };
104
+ /**
105
+ * Flatten every call's evidence into one array in call order (CORE-06 §4.1).
106
+ *
107
+ * The one traversal for consumers that need no attribution (discipline scope, delta
108
+ * judging): calls without evidence are skipped, never substituted for.
109
+ */
110
+ export declare function allFileChanges(input: CovenantInput): FileChange[];
111
+ /**
112
+ * Map a {@link CovenantVerdict} to an exit code (the protocol's forward direction).
113
+ *
114
+ * Responsibility boundary (PRD §4.1): the body emits `0` when upheld and `1` when
115
+ * broken — never the blocking `2`. Translating `1` into `2` is the wrapper's policy.
116
+ */
117
+ export declare function verdictToExitCode(verdict: CovenantVerdict): 0 | 1;
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @polydeukes/core — the thin, domain- and agent-agnostic core.
3
+ *
4
+ * Pre-alpha. The covenant protocol (CORE-01) landed first, then the ROI telemetry
5
+ * collector (CORE-02) and the config loader (CONFIG-01). Pure types and functions,
6
+ * except telemetry's confined I/O functions (appendRecord / readRecords /
7
+ * appendRecordFailOpen — the fail-open wrapper promoted by CORE-05).
8
+ * See https://github.com/huskyhoochu/polydeukes
9
+ */
10
+ import { isPlainObject } from './is-plain-object.js';
11
+ export { ConfigValidationError, DEFAULT_TELEMETRY_LOG_PATH, defineConfig, } from './config.js';
12
+ export { failModeToExitCode, resolveFailMode, } from './fail-policy.js';
13
+ export { isPlainObject } from './is-plain-object.js';
14
+ export { normalizeProtectedPaths } from './protected-paths.js';
15
+ export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, parseRecordLine, readRecords, runGain, } from './telemetry.js';
16
+ export { noopTranscript, transcriptFromInput, } from './transcript.js';
17
+ /**
18
+ * exit-code semantics of the covenant protocol (PRD §4.1).
19
+ *
20
+ * The three codes are distinct and ordered by severity. The covenant *body* only
21
+ * ever emits `0` (uphold) or `1` (break, non-blocking); translating a break into the
22
+ * blocking `2` is the wrapper's job (COVENANT-01), never the core's. The sole place
23
+ * the core itself reaches for `2` is the fail-closed parse path below.
24
+ */
25
+ /** Promise upheld — no violation, the edit/push passes. */
26
+ export const EXIT_UPHOLD = 0;
27
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
28
+ export const EXIT_BREAK_NON_BLOCKING = 1;
29
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
30
+ export const EXIT_BREAK_BLOCKING = 2;
31
+ /**
32
+ * Deserialize stdin-JSON into a {@link CovenantInput} (the protocol's reverse direction).
33
+ *
34
+ * fail-closed (PRD §5.2): this never throws. Any failure — unparseable JSON, an empty
35
+ * payload, a parsed value that is not an object, or a missing required collection —
36
+ * resolves to a blocking `{ ok: false, exitCode: 2 }`. "Cannot judge" means block,
37
+ * so an unjudgeable input can never be mistaken for a valid one.
38
+ */
39
+ export function parseInput(stdinJson) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(stdinJson);
43
+ }
44
+ catch {
45
+ return { ok: false, exitCode: EXIT_BREAK_BLOCKING };
46
+ }
47
+ if (!isPlainObject(parsed)) {
48
+ return { ok: false, exitCode: EXIT_BREAK_BLOCKING };
49
+ }
50
+ const candidate = parsed;
51
+ if (!Array.isArray(candidate.toolCalls) ||
52
+ !Array.isArray(candidate.subagentSpawns) ||
53
+ !Array.isArray(candidate.userMessages)) {
54
+ return { ok: false, exitCode: EXIT_BREAK_BLOCKING };
55
+ }
56
+ return { ok: true, value: candidate };
57
+ }
58
+ /**
59
+ * Flatten every call's evidence into one array in call order (CORE-06 §4.1).
60
+ *
61
+ * The one traversal for consumers that need no attribution (discipline scope, delta
62
+ * judging): calls without evidence are skipped, never substituted for.
63
+ */
64
+ export function allFileChanges(input) {
65
+ const changes = [];
66
+ for (const call of input.toolCalls) {
67
+ if (call.fileChange !== undefined)
68
+ changes.push(call.fileChange);
69
+ }
70
+ return changes;
71
+ }
72
+ /**
73
+ * Map a {@link CovenantVerdict} to an exit code (the protocol's forward direction).
74
+ *
75
+ * Responsibility boundary (PRD §4.1): the body emits `0` when upheld and `1` when
76
+ * broken — never the blocking `2`. Translating `1` into `2` is the wrapper's policy.
77
+ */
78
+ export function verdictToExitCode(verdict) {
79
+ return verdict.upheld ? EXIT_UPHOLD : EXIT_BREAK_NON_BLOCKING;
80
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `isPlainObject` — the workspace's single canonical plain-object predicate.
3
+ *
4
+ * Promoted by CORE-05 from per-package copies: typeof `object`, non-null, not an array.
5
+ */
6
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `isPlainObject` — the workspace's single canonical plain-object predicate.
3
+ *
4
+ * Promoted by CORE-05 from per-package copies: typeof `object`, non-null, not an array.
5
+ */
6
+ export function isPlainObject(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Protected-path normalization — the `protectedPaths` list normalized into the literal
3
+ * path strings the dispatcher contract expects (CONFIG-02). Pure string transformation —
4
+ * zero file I/O, no glob expansion, no path resolution (PRD §4.2).
5
+ */
6
+ /**
7
+ * Normalize the protection surface from a config-shaped spec (PRD §4.2).
8
+ *
9
+ * Processing order: trim each entry → strip a leading `./` → strip a trailing `/` → drop
10
+ * empty-equivalent entries → dedupe on the normalized value, keeping the first occurrence.
11
+ * A `ResolvedConfig` can be passed directly. An absent or empty `protectedPaths` yields
12
+ * `[]` — its meaning is the dispatcher's call.
13
+ */
14
+ export declare function normalizeProtectedPaths(spec: {
15
+ protectedPaths?: string[];
16
+ }): string[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Protected-path normalization — the `protectedPaths` list normalized into the literal
3
+ * path strings the dispatcher contract expects (CONFIG-02). Pure string transformation —
4
+ * zero file I/O, no glob expansion, no path resolution (PRD §4.2).
5
+ */
6
+ /**
7
+ * Normalize the protection surface from a config-shaped spec (PRD §4.2).
8
+ *
9
+ * Processing order: trim each entry → strip a leading `./` → strip a trailing `/` → drop
10
+ * empty-equivalent entries → dedupe on the normalized value, keeping the first occurrence.
11
+ * A `ResolvedConfig` can be passed directly. An absent or empty `protectedPaths` yields
12
+ * `[]` — its meaning is the dispatcher's call.
13
+ */
14
+ export function normalizeProtectedPaths(spec) {
15
+ const seen = new Set();
16
+ const result = [];
17
+ for (const entry of spec.protectedPaths ?? []) {
18
+ let path = entry.trim();
19
+ // Strip to a fixpoint: a single pass would leave residues on repeated prefixes or
20
+ // suffixes ('././x', 'x//'), and a residual './' or '/' silently matches nothing
21
+ // downstream — the fail-open narrowing the contract forbids. Interior segments and
22
+ // absolute paths are path *resolution*, deliberately out of scope.
23
+ while (path.startsWith('./')) {
24
+ path = path.slice(2);
25
+ }
26
+ while (path.endsWith('/')) {
27
+ path = path.slice(0, -1);
28
+ }
29
+ if (path.length === 0 || seen.has(path)) {
30
+ continue;
31
+ }
32
+ seen.add(path);
33
+ result.push(path);
34
+ }
35
+ return result;
36
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ROI telemetry — the single shared collector and its `gain` aggregation (CORE-02).
3
+ *
4
+ * One record is one line of 4-field TSV (PRD §4.1); one append is one write call
5
+ * (PRD §4.2). I/O is confined to exactly two functions — {@link appendRecord} (the
6
+ * only write) and {@link readRecords} (the only read). Formatting, parsing, and
7
+ * aggregation are pure. This is the sole collector: later work calls this API rather
8
+ * than building its own logger.
9
+ */
10
+ /**
11
+ * The five telemetry events. `witnessed` is a first-class event, not a flag on `passed`:
12
+ * a break a human stood behind by supplying the pass condition themselves. `advised` is a
13
+ * violation verdict an advise-level observer recorded but let through; `skipped` is a
14
+ * discipline a surface could not judge at all (no evidence channel) — a no-op that shows
15
+ * up in the data instead of vanishing.
16
+ */
17
+ export type TelemetryEvent = 'passed' | 'blocked' | 'witnessed' | 'advised' | 'skipped';
18
+ /**
19
+ * `TelemetryRecord` — one measured covenant outcome (PRD §4.1).
20
+ *
21
+ * `subject` is the judged target (a file path, etc.); `-` is the documented sentinel
22
+ * for "no subject", carried round-trip like any other value.
23
+ */
24
+ export type TelemetryRecord = {
25
+ timestamp: string;
26
+ event: TelemetryEvent;
27
+ label: string;
28
+ subject: string;
29
+ };
30
+ /** Per-label event counts, keyed by label then event. */
31
+ export type GainSummary = {
32
+ total: number;
33
+ counts: Record<string, Record<TelemetryEvent, number>>;
34
+ };
35
+ /**
36
+ * Serialize a {@link TelemetryRecord} into one newline-terminated TSV line (pure).
37
+ *
38
+ * The returned string already includes the trailing `\n`, so {@link appendRecord}
39
+ * writes it verbatim in a single call.
40
+ */
41
+ export declare function formatRecordLine(record: TelemetryRecord): string;
42
+ /**
43
+ * Parse one TSV line back into a {@link TelemetryRecord}, or `null` if malformed (pure).
44
+ *
45
+ * Tolerates a trailing newline (so it round-trips {@link formatRecordLine}). Returns
46
+ * `null` for the wrong field count, an event outside the five valid events, or an
47
+ * empty line — a malformed line is rejected, never coerced into a bogus record. The one
48
+ * exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as `witnessed`.
49
+ */
50
+ export declare function parseRecordLine(line: string): TelemetryRecord | null;
51
+ /**
52
+ * Append one record to the log at `path` — the only write I/O (PRD §4.2).
53
+ *
54
+ * Exactly one {@link appendFileSync} call per record, writing {@link formatRecordLine}
55
+ * verbatim. Relying on POSIX `O_APPEND` single-write semantics, concurrent appends do
56
+ * not interleave lines.
57
+ *
58
+ * fail-open (PRD §4.3): any fs failure — bad path, permissions, disk — returns
59
+ * `{ ok: false }` and never throws. This is deliberately the opposite direction of the
60
+ * covenant path's fail-closed: the worst outcome of telemetry is a missing datum, never
61
+ * a blocked workflow.
62
+ */
63
+ export declare function appendRecord(path: string, record: TelemetryRecord): {
64
+ ok: boolean;
65
+ };
66
+ /**
67
+ * Append one telemetry record fail-open, timestamping it here (CORE-05).
68
+ *
69
+ * This layer lives above the deliberately mkdir-free {@link appendRecord} (COVENANT-01b:
70
+ * an absent directory is a fail-open `{ ok: false }` for `appendRecord` itself), so this
71
+ * wrapper carries the parent-directory guarantee. The mkdir and the append share one try
72
+ * block, and a failure of either never alters the caller's verdict and never propagates.
73
+ */
74
+ export declare function appendRecordFailOpen(telemetryPath: string, record: Omit<TelemetryRecord, 'timestamp'>): void;
75
+ /**
76
+ * Read every record from the log at `path` — the only read I/O (PRD §4.4).
77
+ *
78
+ * fail-open: an absent file or any read error returns `{ records: [], skipped: 0 }`
79
+ * (an absent log means "nothing collected yet"), never throwing. Corrupt lines
80
+ * ({@link parseRecordLine} → `null`) are skipped and counted; the blank trailing line
81
+ * from the final `\n` is not counted as skipped.
82
+ */
83
+ export declare function readRecords(path: string): {
84
+ records: TelemetryRecord[];
85
+ skipped: number;
86
+ };
87
+ /**
88
+ * Aggregate records into per-label event counts (PRD §4.4, pure).
89
+ *
90
+ * Each label gets its own counter across all five events, so a corrupt or missing
91
+ * event never bleeds counts between labels.
92
+ */
93
+ export declare function aggregateGain(records: TelemetryRecord[]): GainSummary;
94
+ /**
95
+ * `gain` entry point — read the log at `path`, aggregate, and render (PRD §4.4).
96
+ *
97
+ * Composes {@link readRecords} + {@link aggregateGain} + a pure renderer. An absent or
98
+ * empty log yields `no telemetry collected`; a corrupt line is skipped upstream, reported
99
+ * in the output, and does not abort the report.
100
+ */
101
+ export declare function runGain(path: string): string;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * ROI telemetry — the single shared collector and its `gain` aggregation (CORE-02).
3
+ *
4
+ * One record is one line of 4-field TSV (PRD §4.1); one append is one write call
5
+ * (PRD §4.2). I/O is confined to exactly two functions — {@link appendRecord} (the
6
+ * only write) and {@link readRecords} (the only read). Formatting, parsing, and
7
+ * aggregation are pure. This is the sole collector: later work calls this API rather
8
+ * than building its own logger.
9
+ */
10
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
+ const TAB = '\t';
13
+ const VALID_EVENTS = [
14
+ 'passed',
15
+ 'blocked',
16
+ 'witnessed',
17
+ 'advised',
18
+ 'skipped',
19
+ ];
20
+ /**
21
+ * The event name `witnessed` was written under before the rename — a read-only migration
22
+ * seam, never a value this module emits.
23
+ *
24
+ * It exists because the collected log predates the rename: those rows are the sample the
25
+ * milestone journal round argues from, and dropping them as corrupt would delete the
26
+ * measurement instead of migrating it. Compatibility runs one way only — {@link
27
+ * formatRecordLine} has no path back to this name — and the match is the exact literal, so
28
+ * a genuinely corrupt field is still rejected rather than coerced into a fabricated record.
29
+ */
30
+ const LEGACY_WITNESSED_EVENT = 'bypassed';
31
+ /**
32
+ * Replace tab/newline/carriage-return with single spaces (PRD §4.1 line integrity).
33
+ *
34
+ * Without this, a tab or newline inside a field would fabricate extra TSV fields or
35
+ * extra lines — a record is always exactly one line.
36
+ */
37
+ function sanitize(value) {
38
+ return value.replace(/[\t\n\r]/g, ' ');
39
+ }
40
+ /**
41
+ * Serialize a {@link TelemetryRecord} into one newline-terminated TSV line (pure).
42
+ *
43
+ * The returned string already includes the trailing `\n`, so {@link appendRecord}
44
+ * writes it verbatim in a single call.
45
+ */
46
+ export function formatRecordLine(record) {
47
+ const fields = [record.timestamp, record.event, sanitize(record.label), sanitize(record.subject)];
48
+ return `${fields.join(TAB)}\n`;
49
+ }
50
+ /**
51
+ * Parse one TSV line back into a {@link TelemetryRecord}, or `null` if malformed (pure).
52
+ *
53
+ * Tolerates a trailing newline (so it round-trips {@link formatRecordLine}). Returns
54
+ * `null` for the wrong field count, an event outside the five valid events, or an
55
+ * empty line — a malformed line is rejected, never coerced into a bogus record. The one
56
+ * exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as `witnessed`.
57
+ */
58
+ export function parseRecordLine(line) {
59
+ const trimmed = line.replace(/\n$/, '');
60
+ if (trimmed.length === 0) {
61
+ return null;
62
+ }
63
+ const fields = trimmed.split(TAB);
64
+ if (fields.length !== 4) {
65
+ return null;
66
+ }
67
+ const [timestamp, event, label, subject] = fields;
68
+ const resolved = event === LEGACY_WITNESSED_EVENT ? 'witnessed' : event;
69
+ if (!VALID_EVENTS.includes(resolved)) {
70
+ return null;
71
+ }
72
+ return { timestamp, event: resolved, label, subject };
73
+ }
74
+ /**
75
+ * Append one record to the log at `path` — the only write I/O (PRD §4.2).
76
+ *
77
+ * Exactly one {@link appendFileSync} call per record, writing {@link formatRecordLine}
78
+ * verbatim. Relying on POSIX `O_APPEND` single-write semantics, concurrent appends do
79
+ * not interleave lines.
80
+ *
81
+ * fail-open (PRD §4.3): any fs failure — bad path, permissions, disk — returns
82
+ * `{ ok: false }` and never throws. This is deliberately the opposite direction of the
83
+ * covenant path's fail-closed: the worst outcome of telemetry is a missing datum, never
84
+ * a blocked workflow.
85
+ */
86
+ export function appendRecord(path, record) {
87
+ try {
88
+ appendFileSync(path, formatRecordLine(record));
89
+ return { ok: true };
90
+ }
91
+ catch {
92
+ return { ok: false };
93
+ }
94
+ }
95
+ /**
96
+ * Append one telemetry record fail-open, timestamping it here (CORE-05).
97
+ *
98
+ * This layer lives above the deliberately mkdir-free {@link appendRecord} (COVENANT-01b:
99
+ * an absent directory is a fail-open `{ ok: false }` for `appendRecord` itself), so this
100
+ * wrapper carries the parent-directory guarantee. The mkdir and the append share one try
101
+ * block, and a failure of either never alters the caller's verdict and never propagates.
102
+ */
103
+ export function appendRecordFailOpen(telemetryPath, record) {
104
+ try {
105
+ mkdirSync(dirname(telemetryPath), { recursive: true });
106
+ appendRecord(telemetryPath, { timestamp: new Date().toISOString(), ...record });
107
+ }
108
+ catch {
109
+ // fail-open: a logging problem must not alter the verdict or propagate.
110
+ }
111
+ }
112
+ /**
113
+ * Read every record from the log at `path` — the only read I/O (PRD §4.4).
114
+ *
115
+ * fail-open: an absent file or any read error returns `{ records: [], skipped: 0 }`
116
+ * (an absent log means "nothing collected yet"), never throwing. Corrupt lines
117
+ * ({@link parseRecordLine} → `null`) are skipped and counted; the blank trailing line
118
+ * from the final `\n` is not counted as skipped.
119
+ */
120
+ export function readRecords(path) {
121
+ let content;
122
+ try {
123
+ content = readFileSync(path, 'utf-8');
124
+ }
125
+ catch {
126
+ return { records: [], skipped: 0 };
127
+ }
128
+ const records = [];
129
+ let skipped = 0;
130
+ for (const line of content.split('\n')) {
131
+ if (line.length === 0) {
132
+ continue;
133
+ }
134
+ const parsed = parseRecordLine(line);
135
+ if (parsed === null) {
136
+ skipped += 1;
137
+ }
138
+ else {
139
+ records.push(parsed);
140
+ }
141
+ }
142
+ return { records, skipped };
143
+ }
144
+ /**
145
+ * Aggregate records into per-label event counts (PRD §4.4, pure).
146
+ *
147
+ * Each label gets its own counter across all five events, so a corrupt or missing
148
+ * event never bleeds counts between labels.
149
+ */
150
+ export function aggregateGain(records) {
151
+ const counts = {};
152
+ for (const record of records) {
153
+ if (!(record.label in counts)) {
154
+ counts[record.label] = { passed: 0, blocked: 0, witnessed: 0, advised: 0, skipped: 0 };
155
+ }
156
+ counts[record.label][record.event] += 1;
157
+ }
158
+ return { total: records.length, counts };
159
+ }
160
+ /**
161
+ * Render a {@link GainSummary} into human-readable lines (pure).
162
+ *
163
+ * Each label is mentioned with its passed/blocked/witnessed/advised/skipped counts; each
164
+ * is a distinct column, never folded into another (PRD §4.4). A non-zero corrupt-line
165
+ * count is reported rather than hidden — silent skipping would mask log corruption.
166
+ *
167
+ * Two different meanings share the word `skipped`: the per-label EVENT column above,
168
+ * and the unparseable-line count below. They are rendered on separate lines and never
169
+ * summed — `corrupt lines skipped=N` names its own subject so neither reads as the other.
170
+ */
171
+ function renderGain(summary, skipped) {
172
+ if (summary.total === 0 && skipped === 0) {
173
+ return 'no telemetry collected';
174
+ }
175
+ const lines = [`total ${summary.total}`];
176
+ for (const [label, counts] of Object.entries(summary.counts)) {
177
+ lines.push(`${label}: passed=${counts.passed} blocked=${counts.blocked} witnessed=${counts.witnessed} advised=${counts.advised} skipped=${counts.skipped}`);
178
+ }
179
+ if (skipped > 0) {
180
+ lines.push(`corrupt lines skipped=${skipped}`);
181
+ }
182
+ return lines.join('\n');
183
+ }
184
+ /**
185
+ * `gain` entry point — read the log at `path`, aggregate, and render (PRD §4.4).
186
+ *
187
+ * Composes {@link readRecords} + {@link aggregateGain} + a pure renderer. An absent or
188
+ * empty log yields `no telemetry collected`; a corrupt line is skipped upstream, reported
189
+ * in the output, and does not abort the report.
190
+ */
191
+ export function runGain(path) {
192
+ const { records, skipped } = readRecords(path);
193
+ return renderGain(aggregateGain(records), skipped);
194
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `CanonicalTranscript` — the agent-neutral session-query seam (CORE-04).
3
+ *
4
+ * Layering (PRD §1): this seam does not replace `CovenantInput`. The IR (CORE-01) is
5
+ * the *data* a covenant judges; `CanonicalTranscript` is the *behavioral seam* that
6
+ * queries session data — CORE-04 sits on top of CORE-01, and the IR is one source it
7
+ * can wrap. Concrete transcript formats stay in adapters; the core knows only the
8
+ * query vocabulary. Pure types and functions, zero I/O.
9
+ */
10
+ import type { CovenantInput } from './index.js';
11
+ /** One subagent invocation observed in the session. `kind` is an adapter-supplied value. */
12
+ export type SubagentInvocation = {
13
+ kind: string;
14
+ };
15
+ /**
16
+ * One user message observed in the session (PRD §4.1).
17
+ *
18
+ * `timestampMs` is epoch milliseconds. Its absence means the source cannot prove
19
+ * freshness — the fail-closed signal a witness consumer must treat as "not fresh".
20
+ */
21
+ export type TranscriptUserMessage = {
22
+ text: string;
23
+ timestampMs?: number;
24
+ };
25
+ /**
26
+ * One tool call observed in the session (COVENANT-13 §4.2). `name` and `args` are
27
+ * adapter-supplied values — the core knows the query vocabulary, never a tool's name.
28
+ *
29
+ * `succeeded` is three-valued (COVENANT-13b §4.1): `true` = it ran and reported success,
30
+ * `false` = it ran and reported an error, was blocked, or was refused, and absent = the
31
+ * provider cannot observe results at all. A consumer that treats the call as evidence
32
+ * accepts only `true`, so the latter two share a disposition while staying diagnosable.
33
+ */
34
+ export type TranscriptToolCall = {
35
+ name: string;
36
+ args: Record<string, unknown>;
37
+ succeeded?: boolean;
38
+ };
39
+ /**
40
+ * `CanonicalTranscript` — what a covenant may ask about the session (PRD §4.1).
41
+ *
42
+ * Synchronous by design (covenant bodies are short-lived CLI processes) and
43
+ * verdict-free: the seam carries facts only; TTL filtering and token matching belong
44
+ * to the consumer.
45
+ */
46
+ export type CanonicalTranscript = {
47
+ /** Invocations of the given kind, or all of them when omitted. Observation order preserved. */
48
+ findSubagentInvocations(kind?: string): SubagentInvocation[];
49
+ /** Every user message, observation order preserved. Missing timestampMs = freshness unprovable. */
50
+ findUserMessages(): TranscriptUserMessage[];
51
+ /** Tool calls with the given name, or all when omitted. Observation order preserved. */
52
+ findToolCalls(name?: string): TranscriptToolCall[];
53
+ };
54
+ /**
55
+ * The injection-absent default (PRD §4.2): every query answers "nothing happened".
56
+ * A witness consumer naturally converges to fail-closed — no evidence, no skip — and
57
+ * so does a precedent consumer (no evidence, gate stays shut).
58
+ */
59
+ export declare const noopTranscript: CanonicalTranscript;
60
+ /**
61
+ * Wrap a {@link CovenantInput} as a {@link CanonicalTranscript} (PRD §4.2).
62
+ *
63
+ * Exposes `subagentSpawns` as invocations (filtered when a kind is given) and
64
+ * `userMessages` with `timestampMs` omitted — the bare IR cannot prove freshness,
65
+ * and that absence is the *correct* fail-closed signal for a witness consumer.
66
+ * Order preserved; the input is never mutated, and every query returns fresh
67
+ * objects so consumers never hold live aliases into the shared IR.
68
+ *
69
+ * `findToolCalls` projects each call down to `{ name, args }` only: since CORE-06 a
70
+ * call element also carries `fileChange` evidence, and evidence is judgment input, not
71
+ * session history — the two vocabularies stay separate (COVENANT-13 §4.2). `succeeded`
72
+ * stays absent for the same reason it is left three-valued: these calls are the ones
73
+ * being judged right now, so they have not run, and a call can never be its own
74
+ * precedent (COVENANT-13b §4.1).
75
+ */
76
+ export declare function transcriptFromInput(input: CovenantInput): CanonicalTranscript;