@polydeukes/core 0.6.0 → 0.7.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/README.ko.md CHANGED
@@ -1,33 +1,50 @@
1
- <!-- markdownlint-disable MD013 -- npm renders this page; its prose is authored as single-line paragraphs. -->
2
-
3
- # @polydeukes/core
4
-
5
- **한국어** · [English](./README.md)
6
-
7
- > Polydeukes의 얇은 코어입니다. 도메인과 에이전트를 모르는 최소 기반 위에, AI 코딩 파트너와 함께 쓰는 규율(discipline) 프레임워크가 올라갑니다.
8
-
9
- **알파(alpha) 단계입니다.** 마일스톤 사이에 API가 바뀔 수 있습니다. 여기 구현되지 않은 부분은 저장소의 설계 문서가 기준입니다.
10
-
11
- ## 여기 담긴
12
-
13
- 아래 유닛은 청사진이 아니라 전부 구현되고 테스트된 상태입니다.
14
-
15
- - **약속(covenant) 프로토콜.** 모든 약속이 말하는 계약입니다. 입력은 stdin-JSON(`CovenantInput`, `parseInput`)으로 들어오고 판정은 exit code로 나갑니다. 약속 본체는 `0`(지켜짐)과 `1`(어겨짐, 비차단)만 내고, `1`을 차단인 `2`로 바꾸는 일은 래퍼의 몫입니다. 파싱은 fail-closed라서 판정할 수 없는 입력은 조용히 통과하는 대신 `2`로 끝납니다. 각 도구 호출은 선택적으로 자기 `fileChange` 증거를 싣습니다. 판별 유니온(discriminated union, `create`/`modify`/`delete`, 삭제도 1급)으로 표현되는 에이전트 중립 증거를 어댑터가 각자의 원천(가상 적용, git blob)에서 채우므로, 델타 판정이 디스크를 만지지 않습니다. 귀속이 필요 없는 소비자는 `allFileChanges`로 평면 순회합니다.
16
- - **ROI 텔레메트리.** append 전용 라인 수집기(`appendRecord`, `readRecords`)와 `gain` 집계(`runGain`)입니다. 모든 패키지가 공유 fail-open 래퍼(`appendRecordFailOpen`)를 거쳐 이 수집기 하나로 기록합니다. 관측은 fail-open이라서 기록 실패가 판정을 바꾸지 않습니다.
17
- - **config 스키마 v2, 설정은 데이터.** `defineConfig(unknown)`가 파싱된 yml/json 데이터를 검증합니다. 미지의 키는 이 패키지가 소유하는 층위에서 오류로 거부됩니다. 오타가 규율을 조용히 꺼 버리면 안 되기 때문입니다. 어댑터 네임스페이스 안쪽의 내용은 그 어댑터가 검증합니다. `testCmd`는 `{scope}` 템플릿 문자열이고, 호출형 함수로 컴파일되어 돌아옵니다. 같은 계약의 JSON Schema가 `@polydeukes/core/schema.json`으로 배포되며, 검증기와의 동치는 계약 테스트가 강제합니다. 스키마에 `disciplines:`가 추가됐습니다. 사용자가 선언하는 규율(discipline) 항목(항목마다 `declare` 블록 하나, 또는 `draft`)을 순수 데이터로 검증하고, 컴파일은 covenant 패키지가 맡습니다.
18
- - **대수 선언 스키마(`ALGEBRA-01`).** `validateAlgebraDeclaration(unknown)`이 선언 하나의 형상을 검사하고 데이터 그대로 돌려줍니다. 판정은 `judge = relate ∘ extract`이고 블록은 `scope` · `supply` · `extract` · `relate` · `witness` 다섯입니다. 관계(relation) 자리는 일곱 이름으로, 이진 결합자(combinator) 자리는 셋으로 닫혀 있고, 단항 추출(extract) 어휘는 열려 있습니다. 같은 계약의 JSON Schema가 `@polydeukes/core/algebra-declaration.schema.json`으로 배포되며 동치는 같은 방식의 계약 테스트가 강제합니다. 코어는 형상만 검증합니다. 추출을 실행하지도 관계를 평가하지도 않으며, 엔진(`ALGEBRA-02`)이 서기 전까지 `disciplines:`는 이 블록을 받지 않습니다.
19
- - **실패 정책 테이블.** 실패 유형별 fail-open과 fail-closed를 테이블 하나(`resolveFailMode`)가 정합니다. "판정 불가"는 언제나 차단입니다.
20
- - **보호 경로 정규화.** `normalizeProtectedPaths`가 선언된 `protectedPaths` 목록을 디스패처가 대조하는 리터럴 경로 문자열로 다듬습니다(공백 정리, 접두·접미 제거, 중복 제거). 어댑터 설정은 `adapters:` 네임스페이스 맵에 삽니다. 어댑터마다 객체 하나이며, 내용의 검증은 그 어댑터의 몫이고 코어는 그대로 넘깁니다.
21
- - **정규 대화 기록(canonical transcript) 이음새(seam).** 약속이 세션 이력을 물을 때 쓰는 질의 인터페이스 `CanonicalTranscript`입니다. 서브에이전트 호출, 사용자 메시지, 도구 호출(`findToolCalls`, 이름과 인자 모두 어댑터가 채우는 값)을 묻습니다. 기본값은 noop이라 주입받지 못한 소비자는 "아무 일도 없었다"로 수렴하고, 실제 대화 기록은 어댑터 뒤에 있습니다.
22
-
23
- ## 불변식
24
-
25
- - **런타임 의존성 0.** 검증은 직접 작성했고, 배포되는 JSON Schema는 소스가 읽지 않는 별도 산출물입니다.
26
- - **에이전트·도구·언어 리터럴 없음.** 편집 도구의 동사나 테스트 러너의 이름은 config와 어댑터가 채우는 값이지, 이 패키지의 어휘가 아닙니다. 검증 기준의 grep 게이트가 이를 지킵니다.
27
- - **의존은 단방향.** 다른 `@polydeukes/*` 패키지는 core에만 의존하고, core는 그 무엇에도 의존하지 않습니다.
28
-
29
- 아키텍처 청사진과 설계 근거는 [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
30
-
31
- ## 라이선스
32
-
33
- MIT
1
+ # `@polydeukes/core`
2
+
3
+ [English](./README.md) · **한국어**
4
+
5
+ `core` 패키지는 공통 어휘를 정의합니다. 판정기와 어댑터가 함께 쓰는 약속(covenant)
6
+ 프로토콜, 설정 검증, 텔레메트리 도우미, 공유 타입을 제공합니다.
7
+
8
+ <a id="overview"></a>
9
+ ## 개요
10
+
11
+ 공개 계약 심볼은 다음과 같습니다.
12
+
13
+ - `defineConfig`
14
+ - `parseInput`
15
+ - `verdictToExitCode`
16
+ - `normalizeProtectedPaths`
17
+ - `appendRecordFailOpen`
18
+ - `readRecords`
19
+ - `noopTranscript`
20
+ - `ResolvedConfig`
21
+ - `CovenantInput`
22
+ - `CovenantVerdict`
23
+ - `EXIT_UPHOLD`
24
+ - `EXIT_BREAK_NON_BLOCKING`
25
+ - `EXIT_BREAK_BLOCKING`
26
+
27
+ <a id="examples"></a>
28
+ ## 예제
29
+
30
+ ```ts
31
+ import { defineConfig, parseInput } from '@polydeukes/core';
32
+
33
+ const config = defineConfig({
34
+ languages: {
35
+ typescript: {
36
+ productionGlob: 'packages/*/src/**/*.ts',
37
+ testCmd: 'pnpm --filter {scope} test',
38
+ },
39
+ },
40
+ });
41
+
42
+ const payload = parseInput('{"toolCalls":[],"subagentSpawns":[],"userMessages":[]}');
43
+ ```
44
+
45
+ <a id="see-also"></a>
46
+ ## 같이 보기
47
+
48
+ - [`@polydeukes/core` 패키지 레퍼런스](../../docs/reference/packages/core.ko.md)
49
+ - [`설정 레퍼런스`](../../docs/reference/configuration/index.ko.md)
50
+ - [판정기(`covenant` 모듈)](../../docs/reference/packages/polydeukes.ko.md#covenant-module)
package/README.md CHANGED
@@ -1,33 +1,50 @@
1
- <!-- markdownlint-disable MD013 -- npm renders this page; its prose is authored as single-line paragraphs. -->
2
-
3
- # @polydeukes/core
4
-
5
- **English** · [한국어](https://github.com/huskyhoochu/polydeukes/blob/main/packages/core/README.ko.md)
6
-
7
- > The thin, domain- and agent-agnostic core of Polydeukes — a development *discipline* framework for building alongside an AI coding partner.
8
-
9
- **Alpha.** The API surface may move between milestones; for anything not landed here, the repository's design docs are the source of truth.
10
-
11
- ## What lives here
12
-
13
- Every unit below is landed and tested — not blueprint:
14
-
15
- - **Covenant protocol** — the contract every covenant (a deterministic, mutually binding promise) speaks: input arrives as stdin-JSON (`CovenantInput`, `parseInput`), verdicts leave as exit codes. A covenant body only ever emits `0` (upheld) or `1` (broken, non-blocking); translating `1` into the blocking `2` is the wrapper's job. Parsing is fail-closed — an unjudgeable payload resolves to `2`, never to a silent pass. Each tool call optionally carries its own `fileChange` — agent-neutral evidence as a discriminated union (`create`/`modify`/`delete`, deletion first-class) adapters fill from their own sources (virtual apply, git blobs) so delta judgments never touch disk; `allFileChanges` flattens it for consumers that need no attribution.
16
- - **ROI telemetry** — a single append-only, line-oriented collector (`appendRecord`, `readRecords`) plus the `gain` aggregation (`runGain`). Every package writes through this one collector — via `appendRecordFailOpen`, the shared fail-open wrapper. Observation is fail-open: a logging failure never changes a verdict.
17
- - **Config schema v2 (config as data)** — `defineConfig(unknown)` validates parsed yml/json data. Unknown keys are rejected loudly at the level this package owns (a typo must not silently disable a discipline) while an adapter namespace's contents are its own adapter's to validate, `testCmd` is a `{scope}` template string compiled into a callable, and the matching JSON Schema ships as `@polydeukes/core/schema.json` — held equivalent to the validator by a contract test. The schema now includes `disciplines:` — user-declared discipline entries (one `declare` block each, or a `draft`) validated here as pure data and compiled by the covenant package.
18
- - **Algebra declaration schema (`ALGEBRA-01`)** — `validateAlgebraDeclaration(unknown)` checks the shape of one declaration (`judge = relate ∘ extract`: `scope` · `sources` · `supply` · `extract` · `relate` · `witness` blocks — `sources` binds a name to a repo-relative file outside the target, its kind position closed to `file`; a relation position closed to seven names, a binary combinator position closed to three, an open unary extraction vocabulary) and returns it as data. The matching JSON Schema ships as `@polydeukes/core/algebra-declaration.schema.json`, held equivalent by the same kind of contract test. The core validates shape only — it runs no extraction and evaluates no relation; `disciplines:` does not accept these blocks until the engine lands (`ALGEBRA-02`).
19
- - **Fail policy table** — one table (`resolveFailMode`) decides fail-open vs fail-closed per failure kind; "cannot judge" always means block.
20
- - **Protected-path normalization** — `normalizeProtectedPaths` turns the declared `protectedPaths` list into the literal path strings the dispatcher matches on (trim, prefix/suffix strip, dedupe). Adapter settings live in the `adapters:` namespace map — one object per adapter, contents validated by that adapter, passed through the core verbatim.
21
- - **Canonical transcript seam** — `CanonicalTranscript` is the query interface covenants use to ask about session history: subagent invocations, user messages, and tool calls (`findToolCalls`, whose name and args are adapter-supplied values). The default is a noop, so an uninjected consumer converges on "nothing happened"; real transcripts stay behind adapters.
22
-
23
- ## Invariants
24
-
25
- - **Zero runtime dependencies.** Validation is hand-rolled; the published JSON Schema is a sibling artifact the source never reads.
26
- - **No agent, tool, or language literals.** Editor tool verbs and test-runner names are *values* supplied by configs and adapters, never part of this package's vocabulary — grep gates in the acceptance criteria keep it that way.
27
- - **One-way dependencies.** Every other `@polydeukes/*` package depends only on `core`; core depends on none of them.
28
-
29
- See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture blueprint and design rationale.
30
-
31
- ## License
32
-
33
- MIT
1
+ # `@polydeukes/core`
2
+
3
+ **English** · [한국어](./README.ko.md)
4
+
5
+ The core package is the vocabulary layer. It exports the covenant protocol, config validation,
6
+ telemetry helpers, and shared types used by the judge and adapters.
7
+
8
+ <a id="overview"></a>
9
+ ## Overview
10
+
11
+ Public contract symbols include:
12
+
13
+ - `defineConfig`
14
+ - `parseInput`
15
+ - `verdictToExitCode`
16
+ - `normalizeProtectedPaths`
17
+ - `appendRecordFailOpen`
18
+ - `readRecords`
19
+ - `noopTranscript`
20
+ - `ResolvedConfig`
21
+ - `CovenantInput`
22
+ - `CovenantVerdict`
23
+ - `EXIT_UPHOLD`
24
+ - `EXIT_BREAK_NON_BLOCKING`
25
+ - `EXIT_BREAK_BLOCKING`
26
+
27
+ <a id="examples"></a>
28
+ ## Examples
29
+
30
+ ```ts
31
+ import { defineConfig, parseInput } from '@polydeukes/core';
32
+
33
+ const config = defineConfig({
34
+ languages: {
35
+ typescript: {
36
+ productionGlob: 'packages/*/src/**/*.ts',
37
+ testCmd: 'pnpm --filter {scope} test',
38
+ },
39
+ },
40
+ });
41
+
42
+ const payload = parseInput('{"toolCalls":[],"subagentSpawns":[],"userMessages":[]}');
43
+ ```
44
+
45
+ <a id="see-also"></a>
46
+ ## See also
47
+
48
+ - [`@polydeukes/core` package reference](../../docs/reference/packages/core.md)
49
+ - [`Configuration reference`](../../docs/reference/configuration/index.md)
50
+ - [The judge (`covenant` module)](../../docs/reference/packages/polydeukes.md#covenant-module)
package/dist/algebra.d.ts CHANGED
@@ -172,3 +172,12 @@ export type Witnesses = readonly Witness[];
172
172
  * `location`, so a caller validating many declarations sees which one failed.
173
173
  */
174
174
  export declare function validateAlgebraDeclaration(input: unknown, location?: string): AlgebraDeclaration;
175
+ /**
176
+ * `DeclarationChannel` — one evidence channel a declaration binds.
177
+ *
178
+ * `transcript` · `channel` · `command` · `actor` are what a live call carries and `changes`
179
+ * is what a finished change set carries, so the set a declaration reads decides which
180
+ * surfaces can observe it at all.
181
+ */
182
+ export type DeclarationChannel = 'transcript' | 'channel' | 'command' | 'actor' | 'changes';
183
+ export declare function declarationChannels(body: Omit<AlgebraDeclaration, 'discipline'>): DeclarationChannel[];
package/dist/algebra.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * The module knows no judgment. It runs no extraction and evaluates no relation; the
6
6
  * kernel expansion laws quoted on each relation branch below are comments, never code.
7
7
  */
8
- import { validateMechanism } from './catalogue.js';
8
+ import { sourceNames, validateMechanism } from './catalogue.js';
9
9
  import { isPlainObject } from './is-plain-object.js';
10
10
  import { FIXED_SOURCE_NAMES } from './source-names.js';
11
11
  import { ConfigValidationError, isNonEmptyString, isStringArray, rejectUncompilableRegex, rejectUnknownKeys, } from './validation.js';
@@ -436,3 +436,34 @@ export function validateAlgebraDeclaration(input, location = 'declaration') {
436
436
  validateMechanism(declaration, location);
437
437
  return declaration;
438
438
  }
439
+ /** The fixed order of a derived channel list. */
440
+ const DECLARATION_CHANNELS = [
441
+ 'transcript',
442
+ 'channel',
443
+ 'command',
444
+ 'actor',
445
+ 'changes',
446
+ ];
447
+ export function declarationChannels(body) {
448
+ const found = new Set();
449
+ const sources = isPlainObject(body.sources) ? body.sources : {};
450
+ for (const binding of Object.values(sources)) {
451
+ if (!isPlainObject(binding))
452
+ continue;
453
+ if ('transcript' in binding)
454
+ found.add('transcript');
455
+ if ('sidecar' in binding)
456
+ found.add('channel');
457
+ }
458
+ if (isPlainObject(body.scope) && body.scope.source === 'command')
459
+ found.add('command');
460
+ for (const name of sourceNames(body)) {
461
+ if (name === 'command')
462
+ found.add('command');
463
+ if (name === 'actor')
464
+ found.add('actor');
465
+ if (name === 'changes')
466
+ found.add('changes');
467
+ }
468
+ return DECLARATION_CHANNELS.filter((channel) => found.has(channel));
469
+ }
@@ -45,6 +45,13 @@ export type DerivedShape = {
45
45
  };
46
46
  /** Every name's spec. The `Record` type pins the keys to {@link MECHANISM_NAMES}. */
47
47
  export declare const MECHANISM_SHAPES: Record<MechanismName, MechanismShape>;
48
+ /** Every source name the body and the valve read, in declaration order. */
49
+ export declare function sourceNames(declaration: {
50
+ extract?: unknown;
51
+ witness?: {
52
+ extract?: unknown;
53
+ } | undefined;
54
+ }): string[];
48
55
  /**
49
56
  * Read one declaration's shape from its syntax (pure).
50
57
  *
package/dist/catalogue.js CHANGED
@@ -101,12 +101,19 @@ function quotedList(names) {
101
101
  function isTranscriptSource(binding) {
102
102
  return isPlainObject(binding) && binding.transcript === true;
103
103
  }
104
- /** The source names one extract block's `source` steps name, in declaration order. */
104
+ /**
105
+ * The source names one extract block's `source` steps name, in declaration order.
106
+ *
107
+ * Tolerant of an unvalidated block: a pipeline that is not a list, or a step that is not an
108
+ * object, names nothing here and is refused by the grammar validator with its location.
109
+ */
105
110
  function sourceNamesOf(extract) {
106
- if (extract === undefined)
111
+ if (!isPlainObject(extract))
107
112
  return [];
108
113
  const names = [];
109
114
  for (const steps of Object.values(extract)) {
115
+ if (!Array.isArray(steps))
116
+ continue;
110
117
  for (const step of steps) {
111
118
  if (isPlainObject(step) && step.op === 'source' && typeof step.of === 'string') {
112
119
  names.push(step.of);
@@ -116,8 +123,9 @@ function sourceNamesOf(extract) {
116
123
  return names;
117
124
  }
118
125
  /** Every source name the body and the valve read, in declaration order. */
119
- function sourceNames(declaration) {
120
- return [...sourceNamesOf(declaration.extract), ...sourceNamesOf(declaration.witness?.extract)];
126
+ export function sourceNames(declaration) {
127
+ const witness = isPlainObject(declaration.witness) ? declaration.witness : undefined;
128
+ return [...sourceNamesOf(declaration.extract), ...sourceNamesOf(witness?.extract)];
121
129
  }
122
130
  /**
123
131
  * Read one declaration's shape from its syntax (pure).
package/dist/config.d.ts CHANGED
@@ -95,8 +95,18 @@ export type PolydeukesConfig = {
95
95
  /** conventional default applies when omitted */
96
96
  logPath?: string;
97
97
  };
98
- /** user-declared disciplines — validated here, compiled by the covenant package */
98
+ /**
99
+ * user-declared disciplines both surfaces observe — every entry whose declaration binds
100
+ * no evidence channel, plus the drafts. Validated here, compiled by the covenant package
101
+ */
99
102
  disciplines?: (DisciplineEntry | DisciplineDraft)[];
103
+ /**
104
+ * disciplines only the session surface observes — every entry binding at least one of
105
+ * `transcript` · `channel` · `command` · `actor` and never `changes`
106
+ */
107
+ sessionDisciplines?: DisciplineEntry[];
108
+ /** disciplines only the change-set surface observes — every entry binding `changes` */
109
+ changeSetDisciplines?: DisciplineEntry[];
100
110
  /**
101
111
  * TTL witness values for the covenant valve seam — consumed at assembly time,
102
112
  * validated here
@@ -141,6 +151,10 @@ export type ResolvedConfig = {
141
151
  * declared a `disciplines` array, holding exactly its judged entries in order.
142
152
  */
143
153
  disciplines?: DisciplineEntry[];
154
+ /** validated session-surface entries, present whenever the input declared the list */
155
+ sessionDisciplines?: DisciplineEntry[];
156
+ /** validated change-set-surface entries, present whenever the input declared the list */
157
+ changeSetDisciplines?: DisciplineEntry[];
144
158
  /** validated drafts in declaration order (absent when the input carries none) */
145
159
  drafts?: DisciplineDraft[];
146
160
  /** validated witness data, passed through verbatim (absent stays absent) */
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * saw. It stays a pure function — no file I/O and no runtime dependencies: validation is
7
7
  * hand-rolled, and the published JSON Schema is a sibling artifact this source never reads.
8
8
  */
9
- import { validateAlgebraDeclaration } from './algebra.js';
9
+ import { declarationChannels, validateAlgebraDeclaration, } from './algebra.js';
10
10
  import { isPlainObject } from './is-plain-object.js';
11
11
  import { ConfigValidationError, isNonEmptyString, isStringArray, rejectUnknownKeys, } from './validation.js';
12
12
  export { ConfigValidationError } from './validation.js';
@@ -21,6 +21,8 @@ const TOP_LEVEL_KEYS = new Set([
21
21
  'adapters',
22
22
  'telemetry',
23
23
  'disciplines',
24
+ 'sessionDisciplines',
25
+ 'changeSetDisciplines',
24
26
  'witness',
25
27
  ]);
26
28
  const PROFILE_KEYS = new Set(['productionGlob', 'testCmd']);
@@ -94,26 +96,66 @@ function validateDeclareEntry(entry, location) {
94
96
  }
95
97
  validateAlgebraDeclaration({ discipline: entry.id, ...block }, `${location} declare`);
96
98
  }
99
+ /** The three list names an entry can be written in — also the destinations of a misplacement. */
100
+ const DISCIPLINE_LISTS = ['disciplines', 'sessionDisciplines', 'changeSetDisciplines'];
101
+ /** The channels only a live call carries; `changes` is the one only a change set carries. */
102
+ const SESSION_CHANNELS = [
103
+ 'transcript',
104
+ 'channel',
105
+ 'command',
106
+ 'actor',
107
+ ];
97
108
  /**
98
- * Validate the `disciplines` array and split judged entries from drafts. Throws
109
+ * The list an entry's declaration belongs in, from the channels it binds — or `undefined`
110
+ * when it binds both a session channel and `changes`, which no surface observes at once.
111
+ */
112
+ function listFor(channels) {
113
+ const readsSession = channels.some((channel) => SESSION_CHANNELS.includes(channel));
114
+ const readsChanges = channels.includes('changes');
115
+ if (readsSession && readsChanges)
116
+ return undefined;
117
+ if (readsSession)
118
+ return 'sessionDisciplines';
119
+ if (readsChanges)
120
+ return 'changeSetDisciplines';
121
+ return 'disciplines';
122
+ }
123
+ /**
124
+ * Refuse a judged entry written in a list the surface it needs does not read. The channels
125
+ * its declaration binds are the whole answer, so the message names them and the destination:
126
+ * an author who is told only that the placement is wrong meets the same error again.
127
+ */
128
+ function checkPlacement(entry, list, location) {
129
+ const channels = declarationChannels(entry.declare);
130
+ const destination = listFor(channels);
131
+ if (destination === undefined) {
132
+ throw new ConfigValidationError(`${location} reads ${channels.join(', ')}: no surface observes both changes and a session channel`);
133
+ }
134
+ if (destination !== list) {
135
+ const reads = channels.length === 0 ? 'no evidence channel' : channels.join(', ');
136
+ throw new ConfigValidationError(`${location} reads ${reads}: it belongs in ${destination}`);
137
+ }
138
+ }
139
+ /**
140
+ * Validate one discipline list and split judged entries from drafts. Throws
99
141
  * {@link ConfigValidationError} naming the offending entry/key; the validated data passes
100
- * through verbatim, in declaration order.
142
+ * through verbatim, in declaration order. `seenIds` spans the three lists and the meta
143
+ * labels, so a label the telemetry space already carries is refused wherever it is written.
101
144
  */
102
- function validateDisciplines(disciplines) {
145
+ function validateDisciplines(disciplines, list, seenIds) {
103
146
  if (!Array.isArray(disciplines)) {
104
- throw new ConfigValidationError('disciplines must be an array');
147
+ throw new ConfigValidationError(`${list} must be an array`);
105
148
  }
106
149
  const judged = [];
107
150
  const drafts = [];
108
- const seenIds = new Set();
109
151
  disciplines.forEach((entry, index) => {
110
152
  if (!isPlainObject(entry)) {
111
- throw new ConfigValidationError(`disciplines[${index}] must be an object`);
153
+ throw new ConfigValidationError(`${list}[${index}] must be an object`);
112
154
  }
113
155
  if (typeof entry.id !== 'string' || entry.id.length === 0) {
114
- throw new ConfigValidationError(`disciplines[${index}].id must be a non-empty string`);
156
+ throw new ConfigValidationError(`${list}[${index}].id must be a non-empty string`);
115
157
  }
116
- const location = `disciplines[${index}] ('${entry.id}')`;
158
+ const location = `${list}[${index}] ('${entry.id}')`;
117
159
  if (seenIds.has(entry.id)) {
118
160
  throw new ConfigValidationError(`${location} duplicates the id of an earlier entry`);
119
161
  }
@@ -127,10 +169,20 @@ function validateDisciplines(disciplines) {
127
169
  // Selected by the marker's value, so an explicit `draft: undefined` is absence,
128
170
  // like every other optional key in this validator.
129
171
  if (entry.draft !== undefined) {
172
+ // A draft carries no declaration, so it binds no channel and there is nothing for a
173
+ // surface list to observe.
174
+ if (list !== 'disciplines') {
175
+ throw new ConfigValidationError(`${location} a draft belongs in disciplines`);
176
+ }
130
177
  drafts.push(validateDraft(entry, entry.id, location));
131
178
  return;
132
179
  }
133
180
  validateEntryHead(entry, location);
181
+ // Placement first: the derivation is syntactic, so an entry in the wrong list is told
182
+ // where it belongs rather than meeting a grammar error the move would not fix.
183
+ if (isPlainObject(entry.declare)) {
184
+ checkPlacement(entry, list, location);
185
+ }
134
186
  validateDeclareEntry(entry, location);
135
187
  judged.push(entry);
136
188
  });
@@ -250,7 +302,18 @@ export function defineConfig(config) {
250
302
  const resolvedLanguages = validateLanguages(config.languages);
251
303
  const protectedPaths = config.protectedPaths !== undefined ? validateProtectedPaths(config.protectedPaths) : undefined;
252
304
  const adapters = config.adapters !== undefined ? validateAdapters(config.adapters) : undefined;
253
- const split = config.disciplines !== undefined ? validateDisciplines(config.disciplines) : undefined;
305
+ // One id space across the three lists and the meta labels: `explain` and every
306
+ // label-keyed telemetry reader index by the label alone.
307
+ const seenIds = new Set();
308
+ const split = config.disciplines !== undefined
309
+ ? validateDisciplines(config.disciplines, 'disciplines', seenIds)
310
+ : undefined;
311
+ const sessionDisciplines = config.sessionDisciplines !== undefined
312
+ ? validateDisciplines(config.sessionDisciplines, 'sessionDisciplines', seenIds).judged
313
+ : undefined;
314
+ const changeSetDisciplines = config.changeSetDisciplines !== undefined
315
+ ? validateDisciplines(config.changeSetDisciplines, 'changeSetDisciplines', seenIds).judged
316
+ : undefined;
254
317
  const disciplines = split?.judged;
255
318
  const drafts = split !== undefined && split.drafts.length > 0 ? split.drafts : undefined;
256
319
  const logPath = config.telemetry !== undefined ? validateTelemetry(config.telemetry) : undefined;
@@ -263,6 +326,8 @@ export function defineConfig(config) {
263
326
  logPath: logPath ?? DEFAULT_TELEMETRY_LOG_PATH,
264
327
  },
265
328
  ...(disciplines !== undefined && { disciplines }),
329
+ ...(sessionDisciplines !== undefined && { sessionDisciplines }),
330
+ ...(changeSetDisciplines !== undefined && { changeSetDisciplines }),
266
331
  ...(drafts !== undefined && { drafts }),
267
332
  ...(witness !== undefined && { witness }),
268
333
  };
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * (appendRecord / readRecords / appendRecordFailOpen).
7
7
  * See https://github.com/huskyhoochu/polydeukes
8
8
  */
9
- export { type AlgebraDeclaration, BINARY_COMBINATOR_NAMES, type BinaryStep, type ExtractBlock, type ExtractStep, RELATION_NAMES, type RelateEntry, type RelationDecl, type RelationName, type ScopeBlock, SUPPLY_POLICIES, type SupplyBlock, type UnaryStep, validateAlgebraDeclaration, type Witness, type WitnessBlock, type Witnesses, } from './algebra.ts';
9
+ export { type AlgebraDeclaration, BINARY_COMBINATOR_NAMES, type BinaryStep, type DeclarationChannel, declarationChannels, type ExtractBlock, type ExtractStep, RELATION_NAMES, type RelateEntry, type RelationDecl, type RelationName, type ScopeBlock, SUPPLY_POLICIES, type SupplyBlock, type UnaryStep, validateAlgebraDeclaration, type Witness, type WitnessBlock, type Witnesses, } from './algebra.ts';
10
10
  export { AXIS_NAMES, type Axis, type DerivableDeclaration, deriveShape, MECHANISM_NAMES, MECHANISM_SHAPES, type MechanismName, type MechanismShape, } from './catalogue.ts';
11
11
  export { type AlgebraDeclarationBody, ConfigValidationError, DEFAULT_TELEMETRY_LOG_PATH, type DisciplineDraft, type DisciplineEntry, defineConfig, type EnforceLevel, type LanguageProfile, type PolydeukesConfig, type ResolvedConfig, type ResolvedLanguageProfile, } from './config.ts';
12
12
  export { EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD, } from './exit-codes.ts';
@@ -15,4 +15,4 @@ export { isPlainObject } from './is-plain-object.ts';
15
15
  export { normalizeProtectedPaths } from './protected-paths.ts';
16
16
  export { allFileChanges, type ChannelReader, type CovenantInput, type CovenantVerdict, type DispatchOutcome, type FileChange, parseInput, type SourceReader, verdictToExitCode, } from './protocol.ts';
17
17
  export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, type GainSummary, parseRecordLine, readRecords, runGain, SKIP_REASONS, type SkipReason, type TelemetryEvent, type TelemetryRecord, } from './telemetry.ts';
18
- export { type CanonicalTranscript, noopTranscript, type TranscriptToolCall, type TranscriptUserMessage, transcriptFromInput, } from './transcript.ts';
18
+ export { type CanonicalTranscript, noopTranscript, type TranscriptToolCall, type TranscriptUserMessage, transcriptFromInput, transcriptFromSession, } from './transcript.ts';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * (appendRecord / readRecords / appendRecordFailOpen).
7
7
  * See https://github.com/huskyhoochu/polydeukes
8
8
  */
9
- export { BINARY_COMBINATOR_NAMES, RELATION_NAMES, SUPPLY_POLICIES, validateAlgebraDeclaration, } from './algebra.js';
9
+ export { BINARY_COMBINATOR_NAMES, declarationChannels, RELATION_NAMES, SUPPLY_POLICIES, validateAlgebraDeclaration, } from './algebra.js';
10
10
  export { AXIS_NAMES, deriveShape, MECHANISM_NAMES, MECHANISM_SHAPES, } from './catalogue.js';
11
11
  export { ConfigValidationError, DEFAULT_TELEMETRY_LOG_PATH, defineConfig, } from './config.js';
12
12
  export { EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD, } from './exit-codes.js';
@@ -15,4 +15,4 @@ export { isPlainObject } from './is-plain-object.js';
15
15
  export { normalizeProtectedPaths } from './protected-paths.js';
16
16
  export { allFileChanges, parseInput, verdictToExitCode, } from './protocol.js';
17
17
  export { aggregateGain, appendRecord, appendRecordFailOpen, formatRecordLine, parseRecordLine, readRecords, runGain, SKIP_REASONS, } from './telemetry.js';
18
- export { noopTranscript, transcriptFromInput, } from './transcript.js';
18
+ export { noopTranscript, transcriptFromInput, transcriptFromSession, } from './transcript.js';
@@ -91,6 +91,43 @@ export type CovenantInput = {
91
91
  * declaration's own supply policy.
92
92
  */
93
93
  actor?: Actor;
94
+ /**
95
+ * The host's tool roster — values, never vocabulary: `mutating` names the tools whose
96
+ * calls change a file, `shell` the ones carrying a command line, and `commandArgs` the
97
+ * argument keys that command line travels in. Absence leaves the runner on its own
98
+ * default roster.
99
+ */
100
+ tools?: {
101
+ mutating: string[];
102
+ shell: string[];
103
+ commandArgs: string[];
104
+ };
105
+ /**
106
+ * The evidence a live agent session carries that the repository's disk does not. Absence
107
+ * is the absence of a session; a session whose lists are empty is a host that named its
108
+ * evidence and could not deliver it.
109
+ *
110
+ * `evidencePath` is where that evidence was read from, so a surface can protect the file
111
+ * the session is judged from. `userMessages` carries `timestampMs` because a session can
112
+ * prove freshness a bare IR cannot, and `toolCalls` carries `succeeded` because a session
113
+ * can prove a call's outcome. `channels` is the host's own evidence channel text — a
114
+ * runner lifts it into the world it dispatches.
115
+ */
116
+ session?: {
117
+ evidencePath?: string;
118
+ userMessages: {
119
+ text: string;
120
+ timestampMs?: number;
121
+ }[];
122
+ toolCalls: {
123
+ name: string;
124
+ args?: Record<string, unknown>;
125
+ succeeded?: boolean;
126
+ }[];
127
+ channels?: {
128
+ sidecar?: string;
129
+ };
130
+ };
94
131
  };
95
132
  /**
96
133
  * `CovenantVerdict` — the result a covenant body produces.
@@ -64,3 +64,18 @@ export declare const noopTranscript: CanonicalTranscript;
64
64
  * being judged right now: they have not run, and a call can never be its own precedent.
65
65
  */
66
66
  export declare function transcriptFromInput(input: CovenantInput): CanonicalTranscript;
67
+ /**
68
+ * Wrap a {@link CovenantInput}'s `session` as a {@link CanonicalTranscript}.
69
+ *
70
+ * Differs from {@link transcriptFromInput} on exactly the two facts a session proves and a
71
+ * bare IR cannot: `findUserMessages` carries `timestampMs` through, so a witness consumer
72
+ * can read freshness, and `findToolCalls` carries `succeeded` through, so a precedent
73
+ * consumer can read outcome. Both keys stay ABSENT where the session had none — a filled-in
74
+ * timestamp would open a valve from nothing, and a filled-in outcome would turn a call
75
+ * nobody observed into evidence. An absent `args` reads as `{}` because the transcript type
76
+ * requires the field.
77
+ *
78
+ * Order preserved; the input is never mutated, and every query returns fresh objects — args
79
+ * deep-copied — so consumers never hold live aliases into the shared IR.
80
+ */
81
+ export declare function transcriptFromSession(session: NonNullable<CovenantInput['session']>): CanonicalTranscript;
@@ -38,3 +38,29 @@ export function transcriptFromInput(input) {
38
38
  .map((call) => ({ name: call.name, args: structuredClone(call.args ?? {}) })),
39
39
  };
40
40
  }
41
+ /**
42
+ * Wrap a {@link CovenantInput}'s `session` as a {@link CanonicalTranscript}.
43
+ *
44
+ * Differs from {@link transcriptFromInput} on exactly the two facts a session proves and a
45
+ * bare IR cannot: `findUserMessages` carries `timestampMs` through, so a witness consumer
46
+ * can read freshness, and `findToolCalls` carries `succeeded` through, so a precedent
47
+ * consumer can read outcome. Both keys stay ABSENT where the session had none — a filled-in
48
+ * timestamp would open a valve from nothing, and a filled-in outcome would turn a call
49
+ * nobody observed into evidence. An absent `args` reads as `{}` because the transcript type
50
+ * requires the field.
51
+ *
52
+ * Order preserved; the input is never mutated, and every query returns fresh objects — args
53
+ * deep-copied — so consumers never hold live aliases into the shared IR.
54
+ */
55
+ export function transcriptFromSession(session) {
56
+ return {
57
+ findUserMessages: () => session.userMessages.map((message) => ({ ...message })),
58
+ findToolCalls: (name) => session.toolCalls
59
+ .filter((call) => name === undefined || call.name === name)
60
+ .map((call) => ({
61
+ name: call.name,
62
+ args: structuredClone(call.args ?? {}),
63
+ ...(call.succeeded === undefined ? {} : { succeeded: call.succeeded }),
64
+ })),
65
+ };
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polydeukes/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Polydeukes core — covenant protocol, config loader, and transcript interface. Domain- and agent-agnostic. Alpha.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "ajv": "8.20.0",
37
37
  "ajv-formats": "3.0.1",
38
38
  "typescript": "7.0.2",
39
- "vitest": "^4.1.0"
39
+ "vitest": "^5.0.0"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.build.json",
@@ -33,6 +33,18 @@
33
33
  "uniqueItems": true,
34
34
  "items": { "$ref": "#/$defs/discipline" }
35
35
  },
36
+ "sessionDisciplines": {
37
+ "description": "Disciplines only the session surface observes — every entry whose declaration binds a transcript, channel, command, or actor evidence channel. Fully identical entries are copy-paste duplicates (by-id uniqueness and the placement rule are enforced by the runtime validator).",
38
+ "type": "array",
39
+ "uniqueItems": true,
40
+ "items": { "$ref": "#/$defs/discipline" }
41
+ },
42
+ "changeSetDisciplines": {
43
+ "description": "Disciplines only the change-set surface observes — every entry whose declaration binds the changes source. Fully identical entries are copy-paste duplicates (by-id uniqueness and the placement rule are enforced by the runtime validator).",
44
+ "type": "array",
45
+ "uniqueItems": true,
46
+ "items": { "$ref": "#/$defs/discipline" }
47
+ },
36
48
  "witness": { "$ref": "#/$defs/witness" }
37
49
  },
38
50
  "$defs": {