@polydeukes/core 0.3.0 → 0.4.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/config.d.ts CHANGED
@@ -156,7 +156,8 @@ export declare class ConfigValidationError extends Error {
156
156
  * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
157
157
  * is not a plain object, any object level carries an unknown key, `languages` is
158
158
  * missing/empty, any language's `productionGlob` is missing/empty, any `testCmd` is not a
159
- * non-empty string template, `telemetry.logPath` is not a string, `protectedPaths` carries a
160
- * non-string element, or `adapters` is not a map of plain-object namespaces.
159
+ * non-empty string template, `telemetry.logPath` is not a non-empty string after trimming,
160
+ * `protectedPaths` carries a non-string or empty element, or `adapters` is not a map of
161
+ * plain-object namespaces.
161
162
  */
162
163
  export declare function defineConfig(config: unknown): ResolvedConfig;
package/dist/config.js CHANGED
@@ -170,6 +170,11 @@ function validateDisciplines(disciplines) {
170
170
  if (predicate === 'forbid') {
171
171
  const forbid = entry.forbid;
172
172
  if (typeof forbid === 'string') {
173
+ // An empty pattern matches at every position, so the entry would break every
174
+ // in-scope change — rejected like every sibling pattern field.
175
+ if (forbid.length === 0) {
176
+ throw new ConfigValidationError(`${location} forbid must be a non-empty string pattern`);
177
+ }
173
178
  rejectUncompilableRegex(forbid, `${location} forbid`);
174
179
  }
175
180
  else if (isPlainObject(forbid)) {
@@ -178,6 +183,9 @@ function validateDisciplines(disciplines) {
178
183
  if (keys.length !== 1 || keys[0] !== 'added' || typeof forbid.added !== 'string') {
179
184
  throw new ConfigValidationError(`${location} forbid object must have exactly one key 'added' with a string pattern`);
180
185
  }
186
+ if (forbid.added.length === 0) {
187
+ throw new ConfigValidationError(`${location} forbid.added must be a non-empty string pattern`);
188
+ }
181
189
  rejectUncompilableRegex(forbid.added, `${location} forbid.added`);
182
190
  }
183
191
  else {
@@ -193,6 +201,11 @@ function validateDisciplines(disciplines) {
193
201
  if (typeof entry.forbidCommand !== 'string') {
194
202
  throw new ConfigValidationError(`${location} forbidCommand must be a string pattern`);
195
203
  }
204
+ if (entry.forbidCommand.length === 0) {
205
+ // An empty pattern matches every command line — one typo would block every
206
+ // shell call the entry sees.
207
+ throw new ConfigValidationError(`${location} forbidCommand must be a non-empty string pattern`);
208
+ }
196
209
  rejectUncompilableRegex(entry.forbidCommand, `${location} forbidCommand`);
197
210
  }
198
211
  else {
@@ -213,27 +226,9 @@ function validateDisciplines(disciplines) {
213
226
  return disciplines;
214
227
  }
215
228
  /**
216
- * Validate parsed unknown data as a {@link PolydeukesConfig} and return a
217
- * {@link ResolvedConfig} with defaults filled and templates compiled (PRD §4.3).
218
- * Pure — no file I/O.
219
- *
220
- * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
221
- * is not a plain object, any object level carries an unknown key, `languages` is
222
- * missing/empty, any language's `productionGlob` is missing/empty, any `testCmd` is not a
223
- * non-empty string template, `telemetry.logPath` is not a string, `protectedPaths` carries a
224
- * non-string element, or `adapters` is not a map of plain-object namespaces.
229
+ * Validate the `languages` map and compile each profile's `{scope}` template (PRD §4.1).
225
230
  */
226
- export function defineConfig(config) {
227
- if (!isPlainObject(config)) {
228
- throw new ConfigValidationError('config must be a plain object');
229
- }
230
- rejectUnknownKeys(config, TOP_LEVEL_KEYS, 'config');
231
- // `$schema` is an IDE schema reference (CONFIG-03): accepted, type-checked, and
232
- // ignored — it never appears in the resolution output.
233
- if (config.$schema !== undefined && typeof config.$schema !== 'string') {
234
- throw new ConfigValidationError('$schema must be a string');
235
- }
236
- const languages = config.languages;
231
+ function validateLanguages(languages) {
237
232
  if (!isPlainObject(languages) || Object.keys(languages).length === 0) {
238
233
  throw new ConfigValidationError('languages must be a non-empty object');
239
234
  }
@@ -258,56 +253,101 @@ export function defineConfig(config) {
258
253
  testCmd: compileTestCmd(profile.testCmd),
259
254
  };
260
255
  }
261
- if (config.protectedPaths !== undefined && !isStringArray(config.protectedPaths)) {
256
+ return resolvedLanguages;
257
+ }
258
+ /** Validate `protectedPaths` — an array of non-empty strings; the data passes through verbatim. */
259
+ function validateProtectedPaths(protectedPaths) {
260
+ if (!isStringArray(protectedPaths)) {
262
261
  throw new ConfigValidationError('protectedPaths must be an array of strings');
263
262
  }
264
- let adapters;
265
- if (config.adapters !== undefined) {
266
- // Array first: the removed directory-list form deserves a migration hint, not a
267
- // generic type error and an EMPTY array must land here too, never pass as a map.
268
- if (Array.isArray(config.adapters) || !isPlainObject(config.adapters)) {
269
- throw new ConfigValidationError('adapters must be an object map of adapter namespaces — the directory-list form ' +
270
- 'was removed; move directories to protectedPaths');
271
- }
272
- for (const [name, namespace] of Object.entries(config.adapters)) {
273
- if (!isPlainObject(namespace)) {
274
- throw new ConfigValidationError(`adapters.${name} must be an object`);
275
- }
276
- }
277
- adapters = config.adapters;
263
+ // An empty element carries no path meaning and would ride along unnoticed next to
264
+ // valid siblings.
265
+ if (protectedPaths.some((path) => path.length === 0)) {
266
+ throw new ConfigValidationError('protectedPaths must not contain an empty string');
278
267
  }
279
- const disciplines = config.disciplines !== undefined ? validateDisciplines(config.disciplines) : undefined;
280
- let logPath;
281
- if (config.telemetry !== undefined) {
282
- if (!isPlainObject(config.telemetry)) {
283
- throw new ConfigValidationError('telemetry must be an object');
284
- }
285
- rejectUnknownKeys(config.telemetry, TELEMETRY_KEYS, 'telemetry');
286
- if (config.telemetry.logPath !== undefined) {
287
- if (typeof config.telemetry.logPath !== 'string') {
288
- throw new ConfigValidationError('telemetry.logPath must be a string');
289
- }
290
- logPath = config.telemetry.logPath;
291
- }
268
+ return protectedPaths;
269
+ }
270
+ /**
271
+ * Validate the `adapters` map (CONFIG-07 layering) — each namespace is a plain object whose
272
+ * contents belong to that adapter's own validator, so they pass through verbatim.
273
+ */
274
+ function validateAdapters(adapters) {
275
+ // Array first: the removed directory-list form deserves a migration hint, not a
276
+ // generic type error and an EMPTY array must land here too, never pass as a map.
277
+ if (Array.isArray(adapters) || !isPlainObject(adapters)) {
278
+ throw new ConfigValidationError('adapters must be an object map of adapter namespaces — the directory-list form ' +
279
+ 'was removed; move directories to protectedPaths');
292
280
  }
293
- let witness;
294
- if (config.witness !== undefined) {
295
- if (!isPlainObject(config.witness)) {
296
- throw new ConfigValidationError('witness must be an object');
297
- }
298
- rejectUnknownKeys(config.witness, WITNESS_KEYS, 'witness');
299
- const { token, ttlMinutes } = config.witness;
300
- if (typeof token !== 'string' || token.trim().length === 0) {
301
- throw new ConfigValidationError('witness.token must be a non-empty string after trimming');
281
+ for (const [name, namespace] of Object.entries(adapters)) {
282
+ if (!isPlainObject(namespace)) {
283
+ throw new ConfigValidationError(`adapters.${name} must be an object`);
302
284
  }
303
- if (typeof ttlMinutes !== 'number' || !(Number.isFinite(ttlMinutes) && ttlMinutes > 0)) {
304
- throw new ConfigValidationError('witness.ttlMinutes must be a finite number greater than 0');
305
- }
306
- witness = { token, ttlMinutes };
307
285
  }
286
+ return adapters;
287
+ }
288
+ /** Validate the `telemetry` section and return its `logPath` (absent stays undefined). */
289
+ function validateTelemetry(telemetry) {
290
+ if (!isPlainObject(telemetry)) {
291
+ throw new ConfigValidationError('telemetry must be an object');
292
+ }
293
+ rejectUnknownKeys(telemetry, TELEMETRY_KEYS, 'telemetry');
294
+ if (telemetry.logPath === undefined) {
295
+ return undefined;
296
+ }
297
+ if (typeof telemetry.logPath !== 'string') {
298
+ throw new ConfigValidationError('telemetry.logPath must be a string');
299
+ }
300
+ if (telemetry.logPath.trim().length === 0) {
301
+ throw new ConfigValidationError('telemetry.logPath must be a non-empty string after trimming');
302
+ }
303
+ return telemetry.logPath;
304
+ }
305
+ /** Validate the `witness` section (CONFIG-05) — both values are consumed at assembly time. */
306
+ function validateWitness(witness) {
307
+ if (!isPlainObject(witness)) {
308
+ throw new ConfigValidationError('witness must be an object');
309
+ }
310
+ rejectUnknownKeys(witness, WITNESS_KEYS, 'witness');
311
+ const { token, ttlMinutes } = witness;
312
+ if (typeof token !== 'string' || token.trim().length === 0) {
313
+ throw new ConfigValidationError('witness.token must be a non-empty string after trimming');
314
+ }
315
+ if (typeof ttlMinutes !== 'number' || !(Number.isFinite(ttlMinutes) && ttlMinutes > 0)) {
316
+ throw new ConfigValidationError('witness.ttlMinutes must be a finite number greater than 0');
317
+ }
318
+ return { token, ttlMinutes };
319
+ }
320
+ /**
321
+ * Validate parsed unknown data as a {@link PolydeukesConfig} and return a
322
+ * {@link ResolvedConfig} with defaults filled and templates compiled (PRD §4.3).
323
+ * Pure — no file I/O.
324
+ *
325
+ * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
326
+ * is not a plain object, any object level carries an unknown key, `languages` is
327
+ * missing/empty, any language's `productionGlob` is missing/empty, any `testCmd` is not a
328
+ * non-empty string template, `telemetry.logPath` is not a non-empty string after trimming,
329
+ * `protectedPaths` carries a non-string or empty element, or `adapters` is not a map of
330
+ * plain-object namespaces.
331
+ */
332
+ export function defineConfig(config) {
333
+ if (!isPlainObject(config)) {
334
+ throw new ConfigValidationError('config must be a plain object');
335
+ }
336
+ rejectUnknownKeys(config, TOP_LEVEL_KEYS, 'config');
337
+ // `$schema` is an IDE schema reference (CONFIG-03): accepted, type-checked, and
338
+ // ignored — it never appears in the resolution output.
339
+ if (config.$schema !== undefined && typeof config.$schema !== 'string') {
340
+ throw new ConfigValidationError('$schema must be a string');
341
+ }
342
+ const resolvedLanguages = validateLanguages(config.languages);
343
+ const protectedPaths = config.protectedPaths !== undefined ? validateProtectedPaths(config.protectedPaths) : undefined;
344
+ const adapters = config.adapters !== undefined ? validateAdapters(config.adapters) : undefined;
345
+ const disciplines = config.disciplines !== undefined ? validateDisciplines(config.disciplines) : undefined;
346
+ const logPath = config.telemetry !== undefined ? validateTelemetry(config.telemetry) : undefined;
347
+ const witness = config.witness !== undefined ? validateWitness(config.witness) : undefined;
308
348
  return {
309
349
  languages: resolvedLanguages,
310
- ...(config.protectedPaths !== undefined && { protectedPaths: config.protectedPaths }),
350
+ ...(protectedPaths !== undefined && { protectedPaths }),
311
351
  ...(adapters !== undefined && { adapters }),
312
352
  telemetry: {
313
353
  logPath: logPath ?? DEFAULT_TELEMETRY_LOG_PATH,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * exit-codes — the covenant protocol's exit-code vocabulary (CORE-01 PRD §4.1).
3
+ *
4
+ * The three codes are distinct and ordered by severity. The covenant *body* only
5
+ * ever emits `0` (uphold) or `1` (break, non-blocking); translating a break into the
6
+ * blocking `2` is the wrapper's job (COVENANT-01), never the core's. The sole place
7
+ * the core itself reaches for `2` is the fail-closed parse path in the barrel.
8
+ *
9
+ * These live in their own module rather than the barrel because `fail-policy.ts` needs
10
+ * them: importing them from the barrel, which re-exports fail-policy, is an
11
+ * initialization cycle — the constants read as `undefined` depending on which module
12
+ * the runtime evaluates first (CLEANUP-01 F1). The barrel re-exports them, so every
13
+ * consumer outside core still reaches them at the same path.
14
+ */
15
+ /** Promise upheld — no violation, the edit/push passes. */
16
+ export declare const EXIT_UPHOLD = 0;
17
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
18
+ export declare const EXIT_BREAK_NON_BLOCKING = 1;
19
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
20
+ export declare const EXIT_BREAK_BLOCKING = 2;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * exit-codes — the covenant protocol's exit-code vocabulary (CORE-01 PRD §4.1).
3
+ *
4
+ * The three codes are distinct and ordered by severity. The covenant *body* only
5
+ * ever emits `0` (uphold) or `1` (break, non-blocking); translating a break into the
6
+ * blocking `2` is the wrapper's job (COVENANT-01), never the core's. The sole place
7
+ * the core itself reaches for `2` is the fail-closed parse path in the barrel.
8
+ *
9
+ * These live in their own module rather than the barrel because `fail-policy.ts` needs
10
+ * them: importing them from the barrel, which re-exports fail-policy, is an
11
+ * initialization cycle — the constants read as `undefined` depending on which module
12
+ * the runtime evaluates first (CLEANUP-01 F1). The barrel re-exports them, so every
13
+ * consumer outside core still reaches them at the same path.
14
+ */
15
+ /** Promise upheld — no violation, the edit/push passes. */
16
+ export const EXIT_UPHOLD = 0;
17
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
18
+ export const EXIT_BREAK_NON_BLOCKING = 1;
19
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
20
+ export const EXIT_BREAK_BLOCKING = 2;
@@ -5,7 +5,7 @@
5
5
  * performs I/O and never throws. The single source of truth for "which failures
6
6
  * block and which pass through" lives here, not scattered across call sites.
7
7
  */
8
- import { EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from './index.js';
8
+ import { EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from './exit-codes.js';
9
9
  /**
10
10
  * Policy table (PRD §4.1). Null-prototype so lookups can never reach
11
11
  * Object.prototype members ('__proto__', 'toString', …) — those must resolve
package/dist/index.d.ts CHANGED
@@ -8,25 +8,12 @@
8
8
  * See https://github.com/huskyhoochu/polydeukes
9
9
  */
10
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 { EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD, } from './exit-codes.js';
11
12
  export { type FailMode, type FailureKind, failModeToExitCode, resolveFailMode, } from './fail-policy.js';
12
13
  export { isPlainObject } from './is-plain-object.js';
13
14
  export { normalizeProtectedPaths } from './protected-paths.js';
14
15
  export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, type GainSummary, parseRecordLine, readRecords, runGain, type TelemetryEvent, type TelemetryRecord, } from './telemetry.js';
15
16
  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
17
  /**
31
18
  * `FileChange` — one file's mutation evidence around the judged call (CORE-06 §4.1).
32
19
  *
package/dist/index.js CHANGED
@@ -7,27 +7,15 @@
7
7
  * appendRecordFailOpen — the fail-open wrapper promoted by CORE-05).
8
8
  * See https://github.com/huskyhoochu/polydeukes
9
9
  */
10
+ import { EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD } from './exit-codes.js';
10
11
  import { isPlainObject } from './is-plain-object.js';
11
12
  export { ConfigValidationError, DEFAULT_TELEMETRY_LOG_PATH, defineConfig, } from './config.js';
13
+ export { EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD, } from './exit-codes.js';
12
14
  export { failModeToExitCode, resolveFailMode, } from './fail-policy.js';
13
15
  export { isPlainObject } from './is-plain-object.js';
14
16
  export { normalizeProtectedPaths } from './protected-paths.js';
15
17
  export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, parseRecordLine, readRecords, runGain, } from './telemetry.js';
16
18
  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
19
  /**
32
20
  * Deserialize stdin-JSON into a {@link CovenantInput} (the protocol's reverse direction).
33
21
  *
@@ -8,13 +8,18 @@
8
8
  * than building its own logger.
9
9
  */
10
10
  /**
11
- * The five telemetry events. `witnessed` is a first-class event, not a flag on `passed`:
11
+ * The six telemetry events. `witnessed` is a first-class event, not a flag on `passed`:
12
12
  * a break a human stood behind by supplying the pass condition themselves. `advised` is a
13
13
  * violation verdict an advise-level observer recorded but let through; `skipped` is a
14
14
  * discipline a surface could not judge at all (no evidence channel) — a no-op that shows
15
15
  * up in the data instead of vanishing.
16
+ *
17
+ * `unattributed` is the one observation event among them: a protected entry whose state
18
+ * changed with no judgment row explaining it. It sits on a different axis from the five
19
+ * verdicts — `skipped` is an inability known up front, `unattributed` an attribution
20
+ * failure found after the fact — and it never blocks or passes a call.
16
21
  */
17
- export type TelemetryEvent = 'passed' | 'blocked' | 'witnessed' | 'advised' | 'skipped';
22
+ export type TelemetryEvent = 'passed' | 'blocked' | 'witnessed' | 'advised' | 'skipped' | 'unattributed';
18
23
  /**
19
24
  * `TelemetryRecord` — one measured covenant outcome (PRD §4.1).
20
25
  *
@@ -43,7 +48,7 @@ export declare function formatRecordLine(record: TelemetryRecord): string;
43
48
  * Parse one TSV line back into a {@link TelemetryRecord}, or `null` if malformed (pure).
44
49
  *
45
50
  * 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
51
+ * `null` for the wrong field count, an event outside the six valid events, or an
47
52
  * empty line — a malformed line is rejected, never coerced into a bogus record. The one
48
53
  * exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as `witnessed`.
49
54
  */
@@ -87,7 +92,7 @@ export declare function readRecords(path: string): {
87
92
  /**
88
93
  * Aggregate records into per-label event counts (PRD §4.4, pure).
89
94
  *
90
- * Each label gets its own counter across all five events, so a corrupt or missing
95
+ * Each label gets its own counter across all six events, so a corrupt or missing
91
96
  * event never bleeds counts between labels.
92
97
  */
93
98
  export declare function aggregateGain(records: TelemetryRecord[]): GainSummary;
package/dist/telemetry.js CHANGED
@@ -16,6 +16,7 @@ const VALID_EVENTS = [
16
16
  'witnessed',
17
17
  'advised',
18
18
  'skipped',
19
+ 'unattributed',
19
20
  ];
20
21
  /**
21
22
  * The event name `witnessed` was written under before the rename — a read-only migration
@@ -51,7 +52,7 @@ export function formatRecordLine(record) {
51
52
  * Parse one TSV line back into a {@link TelemetryRecord}, or `null` if malformed (pure).
52
53
  *
53
54
  * 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
+ * `null` for the wrong field count, an event outside the six valid events, or an
55
56
  * empty line — a malformed line is rejected, never coerced into a bogus record. The one
56
57
  * exception is {@link LEGACY_WITNESSED_EVENT}, which reads back as `witnessed`.
57
58
  */
@@ -144,14 +145,21 @@ export function readRecords(path) {
144
145
  /**
145
146
  * Aggregate records into per-label event counts (PRD §4.4, pure).
146
147
  *
147
- * Each label gets its own counter across all five events, so a corrupt or missing
148
+ * Each label gets its own counter across all six events, so a corrupt or missing
148
149
  * event never bleeds counts between labels.
149
150
  */
150
151
  export function aggregateGain(records) {
151
152
  const counts = {};
152
153
  for (const record of records) {
153
154
  if (!(record.label in counts)) {
154
- counts[record.label] = { passed: 0, blocked: 0, witnessed: 0, advised: 0, skipped: 0 };
155
+ counts[record.label] = {
156
+ passed: 0,
157
+ blocked: 0,
158
+ witnessed: 0,
159
+ advised: 0,
160
+ skipped: 0,
161
+ unattributed: 0,
162
+ };
155
163
  }
156
164
  counts[record.label][record.event] += 1;
157
165
  }
@@ -160,9 +168,10 @@ export function aggregateGain(records) {
160
168
  /**
161
169
  * Render a {@link GainSummary} into human-readable lines (pure).
162
170
  *
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.
171
+ * Each label is mentioned with its passed/blocked/witnessed/advised/skipped/unattributed
172
+ * counts; each is a distinct column, never folded into another (PRD §4.4). A non-zero
173
+ * corrupt-line count is reported rather than hidden — silent skipping would mask log
174
+ * corruption.
166
175
  *
167
176
  * Two different meanings share the word `skipped`: the per-label EVENT column above,
168
177
  * and the unparseable-line count below. They are rendered on separate lines and never
@@ -174,7 +183,7 @@ function renderGain(summary, skipped) {
174
183
  }
175
184
  const lines = [`total ${summary.total}`];
176
185
  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}`);
186
+ lines.push(`${label}: passed=${counts.passed} blocked=${counts.blocked} witnessed=${counts.witnessed} advised=${counts.advised} skipped=${counts.skipped} unattributed=${counts.unattributed}`);
178
187
  }
179
188
  if (skipped > 0) {
180
189
  lines.push(`corrupt lines skipped=${skipped}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polydeukes/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {
@@ -19,7 +19,7 @@
19
19
  "protectedPaths": {
20
20
  "description": "Raw protected path patterns — normalization happens at resolve time.",
21
21
  "type": "array",
22
- "items": { "type": "string" }
22
+ "items": { "type": "string", "minLength": 1 }
23
23
  },
24
24
  "adapters": {
25
25
  "description": "Adapter namespaces — keys are adapter names, values are each adapter's own settings object (contents not validated by the core schema).",
@@ -64,8 +64,10 @@
64
64
  "additionalProperties": false,
65
65
  "properties": {
66
66
  "logPath": {
67
- "description": "Telemetry log path. Defaults to .polydeukes/roi.log when omitted.",
68
- "type": "string"
67
+ "description": "Telemetry log path. Defaults to .polydeukes/roi.log when omitted. Must be non-empty after trimming.",
68
+ "type": "string",
69
+ "minLength": 1,
70
+ "pattern": "\\S"
69
71
  }
70
72
  }
71
73
  },
@@ -136,13 +138,13 @@
136
138
  "forbid": {
137
139
  "description": "Delta family — string shorthand is equivalent to { added }.",
138
140
  "anyOf": [
139
- { "type": "string", "format": "regex" },
141
+ { "type": "string", "minLength": 1, "format": "regex" },
140
142
  {
141
143
  "type": "object",
142
144
  "additionalProperties": false,
143
145
  "required": ["added"],
144
146
  "properties": {
145
- "added": { "type": "string", "format": "regex" }
147
+ "added": { "type": "string", "minLength": 1, "format": "regex" }
146
148
  }
147
149
  }
148
150
  ]
@@ -172,6 +174,7 @@
172
174
  "forbidCommand": {
173
175
  "description": "Command family — regex over shell command strings.",
174
176
  "type": "string",
177
+ "minLength": 1,
175
178
  "format": "regex"
176
179
  }
177
180
  }