@polydeukes/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/telemetry.js CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * ROI telemetry — the single shared collector and its `gain` aggregation.
3
3
  *
4
- * One record is one line of 4-field TSV; one append is one write call. I/O is confined to
4
+ * One record is one line of TSV four fields, or five when a judgment names witnesses;
5
+ * one append is one write call. I/O is confined to
5
6
  * exactly two functions — {@link appendRecord} (the only write) and {@link readRecords}
6
7
  * (the only read). Formatting, parsing, and aggregation are pure.
7
8
  */
8
9
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
9
10
  import { dirname } from 'node:path';
11
+ /**
12
+ * Why a `skipped` row records no judgment, closed: the surface has no observation channel
13
+ * for what the entry reads, assembly could not compile the entry, or the declaration's own
14
+ * `supply: pass` let an absent source through.
15
+ */
16
+ export const SKIP_REASONS = ['no-observation', 'config-fault', 'supply-pass'];
10
17
  const TAB = '\t';
11
18
  const VALID_EVENTS = [
12
19
  'passed',
@@ -36,23 +43,55 @@ const LEGACY_WITNESSED_EVENT = 'bypassed';
36
43
  function sanitize(value) {
37
44
  return value.replace(/[\t\n\r]/g, ' ');
38
45
  }
46
+ /** True when `text` parses as a JSON array — the only shape the fifth field takes. */
47
+ function isJsonArray(text) {
48
+ try {
49
+ return Array.isArray(JSON.parse(text));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
39
55
  /**
40
56
  * Serialize a {@link TelemetryRecord} into one newline-terminated TSV line (pure).
41
57
  *
42
58
  * The returned string already includes the trailing `\n`, so {@link appendRecord}
43
- * writes it verbatim in a single call.
59
+ * writes it verbatim in a single call. The fifth field appears only when the record
60
+ * carries `witnesses` or a skip `reason`; a record without either writes the four-field
61
+ * line unchanged.
62
+ *
63
+ * Throws when a record claims the fifth field twice, or claims it as a reason on an event
64
+ * that is not `skipped`: both are assembly errors, and writing either one would produce a
65
+ * row no reader can take apart.
44
66
  */
45
67
  export function formatRecordLine(record) {
68
+ if (record.reason !== undefined) {
69
+ if (record.event !== 'skipped') {
70
+ throw new Error(`a skip reason belongs to a skipped row, not to '${record.event}'`);
71
+ }
72
+ if (record.witnesses !== undefined) {
73
+ throw new Error('a record carries either witnesses or a skip reason, never both');
74
+ }
75
+ }
46
76
  const fields = [record.timestamp, record.event, sanitize(record.label), sanitize(record.subject)];
77
+ if (record.witnesses !== undefined) {
78
+ fields.push(sanitize(record.witnesses));
79
+ }
80
+ else if (record.reason !== undefined) {
81
+ fields.push(record.reason);
82
+ }
47
83
  return `${fields.join(TAB)}\n`;
48
84
  }
49
85
  /**
50
86
  * Parse one TSV line back into a {@link TelemetryRecord}, or `null` if malformed (pure).
51
87
  *
52
88
  * Tolerates a trailing newline (so it round-trips {@link formatRecordLine}). Returns
53
- * `null` for the wrong field count, an event outside the six valid events, or an
54
- * empty line — a malformed line is rejected, never coerced into a bogus record. The one
55
- * exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as `witnessed`.
89
+ * `null` for a field count outside four (no fifth field) and five (with one), an event
90
+ * outside the six valid events, or an empty line — a malformed line is rejected, never
91
+ * coerced into a bogus record. The fifth field is read per event: on `skipped` it is a
92
+ * token of {@link SKIP_REASONS} and nothing else, on every other event a JSON array of
93
+ * witnesses. The one exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as
94
+ * `witnessed`.
56
95
  */
57
96
  export function parseRecordLine(line) {
58
97
  const trimmed = line.replace(/\n$/, '');
@@ -60,15 +99,27 @@ export function parseRecordLine(line) {
60
99
  return null;
61
100
  }
62
101
  const fields = trimmed.split(TAB);
63
- if (fields.length !== 4) {
102
+ if (fields.length !== 4 && fields.length !== 5) {
64
103
  return null;
65
104
  }
66
- const [timestamp, event, label, subject] = fields;
105
+ const [timestamp, event, label, subject, fifth] = fields;
67
106
  const resolved = event === LEGACY_WITNESSED_EVENT ? 'witnessed' : event;
68
107
  if (!VALID_EVENTS.includes(resolved)) {
69
108
  return null;
70
109
  }
71
- return { timestamp, event: resolved, label, subject };
110
+ const record = { timestamp, event: resolved, label, subject };
111
+ if (fifth === undefined) {
112
+ return record;
113
+ }
114
+ if (resolved === 'skipped') {
115
+ // A skip carries no witness, so a JSON array here is as corrupt as a near-miss token.
116
+ return SKIP_REASONS.includes(fifth)
117
+ ? { ...record, reason: fifth }
118
+ : null;
119
+ }
120
+ // The fifth field is a JSON array or the line is corrupt: a stray tab inside a subject
121
+ // would otherwise read as a record with the wrong subject and no witnesses.
122
+ return isJsonArray(fifth) ? { ...record, witnesses: fifth } : null;
72
123
  }
73
124
  /**
74
125
  * Append one record to the log at `path` — the only write I/O.
@@ -6,11 +6,7 @@
6
6
  * one source it can wrap. Concrete transcript formats stay in adapters; the core knows only
7
7
  * the query vocabulary. Pure types and functions, zero I/O.
8
8
  */
9
- import type { CovenantInput } from './index.js';
10
- /** One subagent invocation observed in the session. `kind` is an adapter-supplied value. */
11
- export type SubagentInvocation = {
12
- kind: string;
13
- };
9
+ import type { CovenantInput } from './protocol.ts';
14
10
  /**
15
11
  * One user message observed in the session.
16
12
  *
@@ -43,8 +39,6 @@ export type TranscriptToolCall = {
43
39
  * to the consumer.
44
40
  */
45
41
  export type CanonicalTranscript = {
46
- /** Invocations of the given kind, or all of them when omitted. Observation order preserved. */
47
- findSubagentInvocations(kind?: string): SubagentInvocation[];
48
42
  /** Every user message, observation order preserved. Missing timestampMs = freshness unprovable. */
49
43
  findUserMessages(): TranscriptUserMessage[];
50
44
  /** Tool calls with the given name, or all when omitted. Observation order preserved. */
@@ -59,8 +53,7 @@ export declare const noopTranscript: CanonicalTranscript;
59
53
  /**
60
54
  * Wrap a {@link CovenantInput} as a {@link CanonicalTranscript}.
61
55
  *
62
- * Exposes `subagentSpawns` as invocations (filtered when a kind is given) and
63
- * `userMessages` with `timestampMs` omitted — the bare IR cannot prove freshness,
56
+ * Exposes `userMessages` with `timestampMs` omitted the bare IR cannot prove freshness,
64
57
  * and that absence is the *correct* fail-closed signal for a witness consumer.
65
58
  * Order preserved; the input is never mutated, and every query returns fresh
66
59
  * objects so consumers never hold live aliases into the shared IR.
@@ -12,15 +12,13 @@
12
12
  * so does a precedent consumer (no evidence, gate stays shut).
13
13
  */
14
14
  export const noopTranscript = {
15
- findSubagentInvocations: () => [],
16
15
  findUserMessages: () => [],
17
16
  findToolCalls: () => [],
18
17
  };
19
18
  /**
20
19
  * Wrap a {@link CovenantInput} as a {@link CanonicalTranscript}.
21
20
  *
22
- * Exposes `subagentSpawns` as invocations (filtered when a kind is given) and
23
- * `userMessages` with `timestampMs` omitted — the bare IR cannot prove freshness,
21
+ * Exposes `userMessages` with `timestampMs` omitted the bare IR cannot prove freshness,
24
22
  * and that absence is the *correct* fail-closed signal for a witness consumer.
25
23
  * Order preserved; the input is never mutated, and every query returns fresh
26
24
  * objects so consumers never hold live aliases into the shared IR.
@@ -32,9 +30,6 @@ export const noopTranscript = {
32
30
  */
33
31
  export function transcriptFromInput(input) {
34
32
  return {
35
- findSubagentInvocations: (kind) => input.subagentSpawns
36
- .filter((spawn) => kind === undefined || spawn.kind === kind)
37
- .map((spawn) => ({ kind: spawn.kind })),
38
33
  findUserMessages: () => input.userMessages.map((message) => ({ text: message.text })),
39
34
  findToolCalls: (name) => input.toolCalls
40
35
  .filter((call) => name === undefined || call.name === name)
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `validation.ts` — the shape-checking primitives the core's validators share.
3
+ *
4
+ * Internal to the core: `config.ts` and `algebra.ts` both build on these, and neither
5
+ * owns them. Not a public export — `isPlainObject` has its own file because it *is* one.
6
+ */
7
+ /**
8
+ * `ConfigValidationError` — raised when a config fails structural validation.
9
+ *
10
+ * The message names the offending field path so the developer sees exactly what is wrong.
11
+ * This throw is a developer-time error (config authoring), a different axis from the
12
+ * covenant runtime's fail-closed exit code — a bad config should fail loud and early.
13
+ */
14
+ export declare class ConfigValidationError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ /** Throw on the first key outside the allowed vocabulary, naming the key and its location. */
18
+ export declare function rejectUnknownKeys(record: Record<string, unknown>, allowed: ReadonlySet<string>, location: string): void;
19
+ /** True when the value is a string with at least one character. */
20
+ export declare function isNonEmptyString(value: unknown): value is string;
21
+ /** True when the value is an array whose every element is a string. */
22
+ export declare function isStringArray(value: unknown): value is string[];
23
+ /** Throw unless the pattern string compiles with `new RegExp` — compilability only, never run. */
24
+ export declare function rejectUncompilableRegex(pattern: string, location: string): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `validation.ts` — the shape-checking primitives the core's validators share.
3
+ *
4
+ * Internal to the core: `config.ts` and `algebra.ts` both build on these, and neither
5
+ * owns them. Not a public export — `isPlainObject` has its own file because it *is* one.
6
+ */
7
+ /**
8
+ * `ConfigValidationError` — raised when a config fails structural validation.
9
+ *
10
+ * The message names the offending field path so the developer sees exactly what is wrong.
11
+ * This throw is a developer-time error (config authoring), a different axis from the
12
+ * covenant runtime's fail-closed exit code — a bad config should fail loud and early.
13
+ */
14
+ export class ConfigValidationError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'ConfigValidationError';
18
+ }
19
+ }
20
+ /** Throw on the first key outside the allowed vocabulary, naming the key and its location. */
21
+ export function rejectUnknownKeys(record, allowed, location) {
22
+ for (const key of Object.keys(record)) {
23
+ if (!allowed.has(key)) {
24
+ throw new ConfigValidationError(`unknown key '${key}' in ${location}`);
25
+ }
26
+ }
27
+ }
28
+ /** True when the value is a string with at least one character. */
29
+ export function isNonEmptyString(value) {
30
+ return typeof value === 'string' && value.length > 0;
31
+ }
32
+ /** True when the value is an array whose every element is a string. */
33
+ export function isStringArray(value) {
34
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
35
+ }
36
+ /** Throw unless the pattern string compiles with `new RegExp` — compilability only, never run. */
37
+ export function rejectUncompilableRegex(pattern, location) {
38
+ try {
39
+ new RegExp(pattern);
40
+ }
41
+ catch {
42
+ throw new ConfigValidationError(`${location} must be a compilable regular expression`);
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polydeukes/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Polydeukes core — covenant protocol, config loader, and transcript interface. Domain- and agent-agnostic. Alpha.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
19
  },
20
- "./schema.json": "./schema/polydeukes.schema.json"
20
+ "./schema.json": "./schema/polydeukes.schema.json",
21
+ "./algebra-declaration.schema.json": "./schema/algebra-declaration.schema.json"
21
22
  },
22
23
  "files": [
23
24
  "dist",
@@ -0,0 +1,402 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/huskyhoochu/polydeukes/schema/algebra-declaration.schema.json",
4
+ "title": "Polydeukes algebra declaration",
5
+ "description": "One algebra declaration: five blocks, a relation position closed to seven names, a binary combinator position closed to three, and an open unary extraction vocabulary. Reference resolution, relate-entry id uniqueness, and cycle detection are validator-only.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["discipline", "mechanism", "extract", "relate"],
9
+ "properties": {
10
+ "discipline": {
11
+ "type": "string",
12
+ "minLength": 1
13
+ },
14
+ "mechanism": {
15
+ "type": "string",
16
+ "enum": [
17
+ "pairing",
18
+ "companion",
19
+ "monotonic-order",
20
+ "fingerprint-sync",
21
+ "producer-owned",
22
+ "self-absolution-ban",
23
+ "actor-scope",
24
+ "precedent",
25
+ "phase-order",
26
+ "turn-locality",
27
+ "stated-ground",
28
+ "controlled-vocabulary",
29
+ "naming",
30
+ "added-only",
31
+ "one-way-marker",
32
+ "scoped-valve",
33
+ "forbidden-command"
34
+ ]
35
+ },
36
+ "scope": {
37
+ "$ref": "#/$defs/scope"
38
+ },
39
+ "sources": {
40
+ "$ref": "#/$defs/sources"
41
+ },
42
+ "supply": {
43
+ "$ref": "#/$defs/supply"
44
+ },
45
+ "extract": {
46
+ "$ref": "#/$defs/extract"
47
+ },
48
+ "relate": {
49
+ "$ref": "#/$defs/relate"
50
+ },
51
+ "witness": {
52
+ "type": "object",
53
+ "additionalProperties": false,
54
+ "required": ["relate"],
55
+ "properties": {
56
+ "extract": {
57
+ "$ref": "#/$defs/extract"
58
+ },
59
+ "relate": {
60
+ "$ref": "#/$defs/relate"
61
+ }
62
+ }
63
+ }
64
+ },
65
+ "$defs": {
66
+ "extractName": {
67
+ "type": "string",
68
+ "minLength": 1
69
+ },
70
+ "scope": {
71
+ "type": "object",
72
+ "additionalProperties": false,
73
+ "required": ["source"],
74
+ "properties": {
75
+ "source": {
76
+ "type": "string",
77
+ "minLength": 1,
78
+ "not": { "enum": ["changes", "state", "actor"] }
79
+ },
80
+ "include": {
81
+ "type": "array",
82
+ "items": {
83
+ "type": "string",
84
+ "format": "regex"
85
+ }
86
+ },
87
+ "exclude": {
88
+ "type": "array",
89
+ "items": {
90
+ "type": "string",
91
+ "format": "regex"
92
+ }
93
+ },
94
+ "excludeIgnoreCase": {
95
+ "type": "boolean"
96
+ }
97
+ }
98
+ },
99
+ "sources": {
100
+ "description": "What each name outside the target stands for, under the name a declaration reads it by. The kind position is closed to 'file', 'sidecar' and 'transcript', exactly one per entry: a file path is repo-relative, so a leading '/' and a '..' segment are refused, and a sidecar channel's or a transcript's value is the marker true because its location is the host's fact.",
101
+ "type": "object",
102
+ "propertyNames": {
103
+ "type": "string",
104
+ "minLength": 1,
105
+ "not": {
106
+ "enum": ["target.path", "pre", "post", "state", "changes", "command", "actor"]
107
+ }
108
+ },
109
+ "additionalProperties": {
110
+ "type": "object",
111
+ "oneOf": [
112
+ {
113
+ "additionalProperties": false,
114
+ "required": ["file"],
115
+ "properties": {
116
+ "file": {
117
+ "type": "string",
118
+ "minLength": 1,
119
+ "pattern": "^(?!/)(?!(.*/)?\\.\\.(/|$))"
120
+ }
121
+ }
122
+ },
123
+ {
124
+ "additionalProperties": false,
125
+ "required": ["sidecar"],
126
+ "properties": {
127
+ "sidecar": {
128
+ "const": true
129
+ }
130
+ }
131
+ },
132
+ {
133
+ "additionalProperties": false,
134
+ "required": ["transcript"],
135
+ "properties": {
136
+ "transcript": {
137
+ "const": true
138
+ }
139
+ }
140
+ }
141
+ ]
142
+ }
143
+ },
144
+ "supply": {
145
+ "type": "object",
146
+ "properties": {
147
+ "state": {
148
+ "enum": ["error", "pass"]
149
+ }
150
+ },
151
+ "additionalProperties": {
152
+ "enum": ["error", "pass", "empty"]
153
+ }
154
+ },
155
+ "extract": {
156
+ "type": "object",
157
+ "additionalProperties": {
158
+ "$ref": "#/$defs/pipeline"
159
+ }
160
+ },
161
+ "pipeline": {
162
+ "description": "A combinator stands only as the first step: prefixItems governs position 0, items governs the rest.",
163
+ "type": "array",
164
+ "minItems": 1,
165
+ "prefixItems": [
166
+ {
167
+ "$ref": "#/$defs/step"
168
+ }
169
+ ],
170
+ "items": {
171
+ "$ref": "#/$defs/unaryStep"
172
+ }
173
+ },
174
+ "step": {
175
+ "oneOf": [
176
+ {
177
+ "$ref": "#/$defs/combinatorStep"
178
+ },
179
+ {
180
+ "$ref": "#/$defs/unaryStep"
181
+ }
182
+ ]
183
+ },
184
+ "combinatorStep": {
185
+ "type": "object",
186
+ "required": ["op"],
187
+ "properties": {
188
+ "op": {
189
+ "enum": ["union", "onlyIn", "intersect"]
190
+ }
191
+ },
192
+ "oneOf": [
193
+ {
194
+ "additionalProperties": false,
195
+ "required": ["op", "of"],
196
+ "properties": {
197
+ "op": {
198
+ "enum": ["union", "intersect"]
199
+ },
200
+ "of": {
201
+ "type": "array",
202
+ "minItems": 2,
203
+ "maxItems": 2,
204
+ "items": {
205
+ "$ref": "#/$defs/extractName"
206
+ },
207
+ "uniqueItems": true
208
+ }
209
+ }
210
+ },
211
+ {
212
+ "additionalProperties": false,
213
+ "required": ["op", "of", "notIn"],
214
+ "properties": {
215
+ "op": {
216
+ "const": "onlyIn"
217
+ },
218
+ "of": {
219
+ "$ref": "#/$defs/extractName"
220
+ },
221
+ "notIn": {
222
+ "$ref": "#/$defs/extractName"
223
+ }
224
+ }
225
+ }
226
+ ]
227
+ },
228
+ "unaryStep": {
229
+ "description": "Open vocabulary: any name outside the three combinators, arguments unconstrained — except that referencing two extractions (an array `of`, or a `notIn`) makes the step a combinator by shape.",
230
+ "type": "object",
231
+ "required": ["op"],
232
+ "properties": {
233
+ "op": {
234
+ "type": "string",
235
+ "minLength": 1,
236
+ "not": {
237
+ "enum": ["union", "onlyIn", "intersect"]
238
+ }
239
+ },
240
+ "of": {
241
+ "not": {
242
+ "type": "array"
243
+ }
244
+ }
245
+ },
246
+ "not": {
247
+ "required": ["notIn"]
248
+ }
249
+ },
250
+ "relate": {
251
+ "type": "array",
252
+ "minItems": 1,
253
+ "items": {
254
+ "$ref": "#/$defs/relateEntry"
255
+ }
256
+ },
257
+ "relateEntry": {
258
+ "type": "object",
259
+ "additionalProperties": false,
260
+ "required": ["id", "relation"],
261
+ "properties": {
262
+ "id": {
263
+ "type": "string",
264
+ "minLength": 1
265
+ },
266
+ "relation": {
267
+ "$ref": "#/$defs/relation"
268
+ },
269
+ "message": {
270
+ "type": "string",
271
+ "minLength": 1
272
+ },
273
+ "messageBySide": {
274
+ "type": "object",
275
+ "additionalProperties": false,
276
+ "required": ["left", "right"],
277
+ "properties": {
278
+ "left": {
279
+ "type": "string",
280
+ "minLength": 1
281
+ },
282
+ "right": {
283
+ "type": "string",
284
+ "minLength": 1
285
+ }
286
+ }
287
+ }
288
+ },
289
+ "oneOf": [
290
+ {
291
+ "description": "A plain message, on any relation.",
292
+ "required": ["message"],
293
+ "not": {
294
+ "required": ["messageBySide"]
295
+ }
296
+ },
297
+ {
298
+ "description": "Side-keyed messages, only on Equal — the one relation with two sides.",
299
+ "required": ["messageBySide"],
300
+ "not": {
301
+ "required": ["message"]
302
+ },
303
+ "properties": {
304
+ "relation": {
305
+ "properties": {
306
+ "op": {
307
+ "const": "equal"
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ ]
314
+ },
315
+ "relation": {
316
+ "type": "object",
317
+ "required": ["op"],
318
+ "properties": {
319
+ "op": {
320
+ "enum": ["empty", "nonEmpty", "equal", "subset", "implies", "ordered", "unchanged"]
321
+ }
322
+ },
323
+ "oneOf": [
324
+ {
325
+ "additionalProperties": false,
326
+ "required": ["op", "of"],
327
+ "properties": {
328
+ "op": {
329
+ "enum": ["empty", "nonEmpty", "unchanged"]
330
+ },
331
+ "of": {
332
+ "$ref": "#/$defs/extractName"
333
+ }
334
+ }
335
+ },
336
+ {
337
+ "additionalProperties": false,
338
+ "required": ["op", "of"],
339
+ "properties": {
340
+ "op": {
341
+ "const": "equal"
342
+ },
343
+ "of": {
344
+ "type": "array",
345
+ "minItems": 2,
346
+ "maxItems": 2,
347
+ "uniqueItems": true,
348
+ "items": {
349
+ "$ref": "#/$defs/extractName"
350
+ }
351
+ }
352
+ }
353
+ },
354
+ {
355
+ "additionalProperties": false,
356
+ "required": ["op", "of", "in"],
357
+ "properties": {
358
+ "op": {
359
+ "const": "subset"
360
+ },
361
+ "of": {
362
+ "$ref": "#/$defs/extractName"
363
+ },
364
+ "in": {
365
+ "$ref": "#/$defs/extractName"
366
+ }
367
+ }
368
+ },
369
+ {
370
+ "additionalProperties": false,
371
+ "required": ["op", "of", "requires"],
372
+ "properties": {
373
+ "op": {
374
+ "const": "implies"
375
+ },
376
+ "of": {
377
+ "$ref": "#/$defs/extractName"
378
+ },
379
+ "requires": {
380
+ "$ref": "#/$defs/extractName"
381
+ }
382
+ }
383
+ },
384
+ {
385
+ "additionalProperties": false,
386
+ "required": ["op", "of"],
387
+ "properties": {
388
+ "op": {
389
+ "const": "ordered"
390
+ },
391
+ "of": {
392
+ "$ref": "#/$defs/extractName"
393
+ },
394
+ "strict": {
395
+ "type": "boolean"
396
+ }
397
+ }
398
+ }
399
+ ]
400
+ }
401
+ }
402
+ }