@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyung Soo Seung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,32 @@
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) 항목(`forbid` / `immutable` / `forbidCommand` / `requirePrecedent`, 항목당 술어 정확히 하나)을 순수 데이터로 검증하고, 컴파일은 covenant 패키지가 맡습니다. `requirePrecedent`의 증거 어휘는 `adapters:`와 같은 방식으로 계층이 나뉩니다. 셸 명령은 에이전트를 넘나드는 표면이라 코어가 `command` 키를 전부 검증하고, 나머지 키는 컨테이너 형태만(키가 정확히 하나인 평면 객체) 검증한 뒤 값은 그대로 넘겨 그 어휘를 가진 어댑터가 판정합니다.
18
+ - **실패 정책 테이블.** 실패 유형별 fail-open과 fail-closed를 테이블 하나(`resolveFailMode`)가 정합니다. "판정 불가"는 언제나 차단입니다.
19
+ - **보호 경로 정규화.** `normalizeProtectedPaths`가 선언된 `protectedPaths` 목록을 디스패처가 대조하는 리터럴 경로 문자열로 다듬습니다(공백 정리, 접두·접미 제거, 중복 제거). 어댑터 설정은 `adapters:` 네임스페이스 맵에 삽니다. 어댑터마다 객체 하나이며, 내용의 검증은 그 어댑터의 몫이고 코어는 그대로 넘깁니다.
20
+ - **정규 대화 기록(canonical transcript) 이음새(seam).** 약속이 세션 이력을 물을 때 쓰는 질의 인터페이스 `CanonicalTranscript`입니다. 서브에이전트 호출, 사용자 메시지, 도구 호출(`findToolCalls`, 이름과 인자 모두 어댑터가 채우는 값)을 묻습니다. 기본값은 noop이라 주입받지 못한 소비자는 "아무 일도 없었다"로 수렴하고, 실제 대화 기록은 어댑터 뒤에 있습니다.
21
+
22
+ ## 불변식
23
+
24
+ - **런타임 의존성 0.** 검증은 직접 작성했고, 배포되는 JSON Schema는 소스가 읽지 않는 별도 산출물입니다.
25
+ - **에이전트·도구·언어 리터럴 없음.** 편집 도구의 동사나 테스트 러너의 이름은 config와 어댑터가 채우는 값이지, 이 패키지의 어휘가 아닙니다. 검증 기준의 grep 게이트가 이를 지킵니다.
26
+ - **의존은 단방향.** 다른 `@polydeukes/*` 패키지는 core에만 의존하고, core는 그 무엇에도 의존하지 않습니다.
27
+
28
+ 아키텍처 청사진과 설계 근거는 [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
29
+
30
+ ## 라이선스
31
+
32
+ MIT
package/README.md ADDED
@@ -0,0 +1,32 @@
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 (`forbid` / `immutable` / `forbidCommand` / `requirePrecedent`, exactly one predicate each) validated here as pure data and compiled by the covenant package. `requirePrecedent`'s evidence vocabulary is layered the way `adapters:` is: the core fully validates the `command` key (a shell command is the agent-crossing surface) and validates only the container shape of every other key — a flat object holding exactly one key — passing the value through verbatim for its owning adapter to judge.
18
+ - **Fail policy table** — one table (`resolveFailMode`) decides fail-open vs fail-closed per failure kind; "cannot judge" always means block.
19
+ - **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.
20
+ - **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.
21
+
22
+ ## Invariants
23
+
24
+ - **Zero runtime dependencies.** Validation is hand-rolled; the published JSON Schema is a sibling artifact the source never reads.
25
+ - **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.
26
+ - **One-way dependencies.** Every other `@polydeukes/*` package depends only on `core`; core depends on none of them.
27
+
28
+ See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture blueprint and design rationale.
29
+
30
+ ## License
31
+
32
+ MIT
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Config schema v2 + `defineConfig()` validator — config as data (CONFIG-04).
3
+ *
4
+ * This is the single settings surface the three areas share (covenant's `protectedPaths`,
5
+ * ledger's `testCmd`, memory's ticket pattern all reference this shape). Since schema v2 the
6
+ * input is pure JSON-representable data: `testCmd` is a `{scope}` template string, and
7
+ * `defineConfig` is the runtime validator for parsed unknown data (the CONFIG-03 loader feeds
8
+ * it values the compiler never saw). It stays a pure function — zero file I/O, zero runtime
9
+ * dependencies (hand-rolled validation; the published JSON Schema is a sibling artifact the
10
+ * source never reads).
11
+ */
12
+ /** Conventional default telemetry log path (PRD §4.3) — local-only observation data. */
13
+ export declare const DEFAULT_TELEMETRY_LOG_PATH = ".polydeukes/roi.log";
14
+ /**
15
+ * `LanguageProfile` — the unit of the language axis (PRD §4.1).
16
+ *
17
+ * `testCmd` is a shell command template: every literal `{scope}` token is substituted at
18
+ * resolve time, and the core only carries the resulting string — it never interprets it.
19
+ */
20
+ export type LanguageProfile = {
21
+ /** what counts as production source for this language — required */
22
+ productionGlob: string | string[];
23
+ /**
24
+ * shell command template that verifies the given scope — `{scope}` placeholders are
25
+ * substituted at resolve time; the core never interprets the resulting string
26
+ */
27
+ testCmd: string;
28
+ };
29
+ /**
30
+ * `DisciplineForbid` — the delta-family predicate value (COVENANT-10 §4.1).
31
+ *
32
+ * The string shorthand is equivalent to `{ added }`; `removed`/`present` directions are
33
+ * deferred (COVENANT-12) and rejected by validation.
34
+ */
35
+ export type DisciplineForbid = string | {
36
+ added: string;
37
+ };
38
+ /**
39
+ * `DisciplineEntry` — one user-declared discipline (COVENANT-10 §4.1). Pure JSON data.
40
+ *
41
+ * Exactly one predicate key (`forbid` | `immutable` | `forbidCommand` |
42
+ * `requirePrecedent`) per entry; `in`/`except` scope the delta and context families.
43
+ * Compilation is the covenant package's job — the core validates compilability of regex
44
+ * strings but never executes them.
45
+ */
46
+ export type DisciplineEntry = {
47
+ /** unique handle — telemetry label and verdict reason prefix */
48
+ id: string;
49
+ /** prose rationale, documentation only — never judged */
50
+ why?: string;
51
+ /** delta/context-family scope: glob(s) the file path must match (absent = every file change) */
52
+ in?: string | string[];
53
+ /** delta/context-family scope: glob(s) excluded after `in` */
54
+ except?: string | string[];
55
+ /** delta family — string shorthand = { added } */
56
+ forbid?: DisciplineForbid;
57
+ /** path family — its own glob is the scope */
58
+ immutable?: string | string[];
59
+ /** command family — regex over shell command strings */
60
+ forbidCommand?: string;
61
+ /** context-family trigger: added-direction delta regex (absent = every in-scope change) */
62
+ when?: string;
63
+ /**
64
+ * context family — the session evidence one edit requires beforehand. Exactly one
65
+ * evidence key. The core owns and fully validates `command`; every other key is
66
+ * adapter vocabulary whose value passes through verbatim (CONFIG-07 layering).
67
+ */
68
+ requirePrecedent?: Record<string, unknown>;
69
+ };
70
+ /**
71
+ * `PolydeukesConfig` — the input shape a user writes (PRD §4.1). JSON-serializable data.
72
+ *
73
+ * Language keys (`typescript`, `python`, …) are user *values*, not the core's vocabulary —
74
+ * no language or tool literal appears in the core source.
75
+ */
76
+ export type PolydeukesConfig = {
77
+ /** IDE schema reference (CONFIG-03) — accepted and ignored, never part of the resolution */
78
+ $schema?: string;
79
+ /** language axis, first-class. keys are user values ('typescript', 'python', …) */
80
+ languages: Record<string, LanguageProfile>;
81
+ /** raw protected path patterns — normalization is CONFIG-02's job */
82
+ protectedPaths?: string[];
83
+ /**
84
+ * adapter namespaces (CONFIG-07) — keys are ecosystem values (never validated), each
85
+ * value is that adapter's own settings object, passed through verbatim (the vocabulary
86
+ * belongs to the adapter, whose own validator judges the contents)
87
+ */
88
+ adapters?: Record<string, Record<string, unknown>>;
89
+ telemetry?: {
90
+ /** conventional default applies when omitted (§4.3) */
91
+ logPath?: string;
92
+ };
93
+ /** user-declared disciplines — validated here, compiled by the covenant package */
94
+ disciplines?: DisciplineEntry[];
95
+ /**
96
+ * TTL witness values for the covenant valve seam (CONFIG-05) — consumed at
97
+ * assembly time, validated here
98
+ */
99
+ witness?: {
100
+ /**
101
+ * the agreed phrase a human types alone on a message's first line — quoting it
102
+ * mid-sentence is a mention, not an invocation (COVENANT-15). Non-empty after
103
+ * trimming; the value itself is free (provenance, not secrecy, is the defence)
104
+ */
105
+ token: string;
106
+ /** validity window in minutes from the user message's timestamp — finite and > 0 */
107
+ ttlMinutes: number;
108
+ };
109
+ };
110
+ /**
111
+ * `ResolvedLanguageProfile` — a {@link LanguageProfile} with its template compiled.
112
+ *
113
+ * Consumers keep the callable shape (`testCmd(scope)`), identical to schema v1 (LEDGER-05).
114
+ */
115
+ export type ResolvedLanguageProfile = {
116
+ productionGlob: string | string[];
117
+ /** compiled from the template — consumers keep the callable shape (LEDGER-05) */
118
+ testCmd: (scope: string) => string;
119
+ };
120
+ /**
121
+ * `ResolvedConfig` — a validated config with defaults filled and templates compiled.
122
+ *
123
+ * Consumers (covenant/ledger/memory) read `telemetry.logPath` without optional handling.
124
+ */
125
+ export type ResolvedConfig = {
126
+ languages: Record<string, ResolvedLanguageProfile>;
127
+ protectedPaths?: string[];
128
+ /** validated adapter namespaces, passed through verbatim (absent stays absent) */
129
+ adapters?: Record<string, Record<string, unknown>>;
130
+ telemetry: {
131
+ logPath: string;
132
+ };
133
+ /** validated discipline data, passed through verbatim (absent stays absent) */
134
+ disciplines?: DisciplineEntry[];
135
+ /** validated witness data, passed through verbatim (absent stays absent) */
136
+ witness?: {
137
+ token: string;
138
+ ttlMinutes: number;
139
+ };
140
+ };
141
+ /**
142
+ * `ConfigValidationError` — raised when a config fails structural validation (PRD §4.3).
143
+ *
144
+ * The message names the offending field path so the developer sees exactly what is wrong.
145
+ * This throw is a developer-time error (config authoring), a different axis from the
146
+ * covenant runtime's fail-closed exit code — a bad config should fail loud and early.
147
+ */
148
+ export declare class ConfigValidationError extends Error {
149
+ constructor(message: string);
150
+ }
151
+ /**
152
+ * Validate parsed unknown data as a {@link PolydeukesConfig} and return a
153
+ * {@link ResolvedConfig} with defaults filled and templates compiled (PRD §4.3).
154
+ * Pure — no file I/O.
155
+ *
156
+ * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
157
+ * is not a plain object, any object level carries an unknown key, `languages` is
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.
161
+ */
162
+ export declare function defineConfig(config: unknown): ResolvedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Config schema v2 + `defineConfig()` validator — config as data (CONFIG-04).
3
+ *
4
+ * This is the single settings surface the three areas share (covenant's `protectedPaths`,
5
+ * ledger's `testCmd`, memory's ticket pattern all reference this shape). Since schema v2 the
6
+ * input is pure JSON-representable data: `testCmd` is a `{scope}` template string, and
7
+ * `defineConfig` is the runtime validator for parsed unknown data (the CONFIG-03 loader feeds
8
+ * it values the compiler never saw). It stays a pure function — zero file I/O, zero runtime
9
+ * dependencies (hand-rolled validation; the published JSON Schema is a sibling artifact the
10
+ * source never reads).
11
+ */
12
+ import { isPlainObject } from './is-plain-object.js';
13
+ /** Conventional default telemetry log path (PRD §4.3) — local-only observation data. */
14
+ export const DEFAULT_TELEMETRY_LOG_PATH = '.polydeukes/roi.log';
15
+ /**
16
+ * `ConfigValidationError` — raised when a config fails structural validation (PRD §4.3).
17
+ *
18
+ * The message names the offending field path so the developer sees exactly what is wrong.
19
+ * This throw is a developer-time error (config authoring), a different axis from the
20
+ * covenant runtime's fail-closed exit code — a bad config should fail loud and early.
21
+ */
22
+ export class ConfigValidationError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'ConfigValidationError';
26
+ }
27
+ }
28
+ /** The exact key vocabulary of each object level — anything else is a typo, rejected loudly. */
29
+ const TOP_LEVEL_KEYS = new Set([
30
+ '$schema',
31
+ 'languages',
32
+ 'protectedPaths',
33
+ 'adapters',
34
+ 'telemetry',
35
+ 'disciplines',
36
+ 'witness',
37
+ ]);
38
+ const PROFILE_KEYS = new Set(['productionGlob', 'testCmd']);
39
+ const TELEMETRY_KEYS = new Set(['logPath']);
40
+ const WITNESS_KEYS = new Set(['token', 'ttlMinutes']);
41
+ const DISCIPLINE_KEYS = new Set([
42
+ 'id',
43
+ 'why',
44
+ 'in',
45
+ 'except',
46
+ 'forbid',
47
+ 'immutable',
48
+ 'forbidCommand',
49
+ 'when',
50
+ 'requirePrecedent',
51
+ ]);
52
+ const PREDICATE_KEYS = ['forbid', 'immutable', 'forbidCommand', 'requirePrecedent'];
53
+ /** Predicate families that `in`/`except` may scope — delta and context (COVENANT-13 §4.1). */
54
+ const SCOPED_PREDICATE_KEYS = new Set(['forbid', 'requirePrecedent']);
55
+ /** Throw on the first key outside the allowed vocabulary, naming the key and its location. */
56
+ function rejectUnknownKeys(record, allowed, location) {
57
+ for (const key of Object.keys(record)) {
58
+ if (!allowed.has(key)) {
59
+ throw new ConfigValidationError(`unknown key '${key}' in ${location}`);
60
+ }
61
+ }
62
+ }
63
+ /** True when the value is an array whose every element is a string. */
64
+ function isStringArray(value) {
65
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
66
+ }
67
+ /** True when the glob value is a present, non-empty string or a non-empty array of non-empty strings. */
68
+ function isValidGlob(glob) {
69
+ if (typeof glob === 'string') {
70
+ return glob.length > 0;
71
+ }
72
+ if (Array.isArray(glob)) {
73
+ return glob.length > 0 && glob.every((entry) => typeof entry === 'string' && entry.length > 0);
74
+ }
75
+ return false;
76
+ }
77
+ /**
78
+ * Compile a `{scope}` template into the callable consumers use (PRD §4.2).
79
+ *
80
+ * Exactly the literal token `{scope}` is substituted, at every occurrence (`replaceAll`
81
+ * semantics). Other braces (`${VAR}`, `{a,b}`, `awk '{print}'`) are the shell's own
82
+ * vocabulary and pass through untouched.
83
+ */
84
+ function compileTestCmd(template) {
85
+ // Callback form: a string replacement would interpret `$`-patterns ($$, $&, $`, $')
86
+ // via GetSubstitution, breaking literal insertion for scopes containing `$`.
87
+ return (scope) => template.replaceAll('{scope}', () => scope);
88
+ }
89
+ /** Throw unless the pattern string compiles with `new RegExp` — compilability only, never run. */
90
+ function rejectUncompilableRegex(pattern, location) {
91
+ try {
92
+ new RegExp(pattern);
93
+ }
94
+ catch {
95
+ throw new ConfigValidationError(`${location} must be a compilable regular expression`);
96
+ }
97
+ }
98
+ /**
99
+ * Validate a context-family `requirePrecedent` value (COVENANT-13 §4.1).
100
+ *
101
+ * Evidence vocabulary is layered: the container (a flat object holding exactly one
102
+ * evidence key) is the core's, and so is the `command` key — a shell command is the
103
+ * agent-crossing surface, fully validated here. Every other key belongs to an adapter,
104
+ * whose own validator judges the value; the core passes it through verbatim and never
105
+ * inspects it (CONFIG-07 layering). An unrecognized evidence key fails closed at
106
+ * assembly time, not here.
107
+ */
108
+ function validateRequirePrecedent(evidence, location) {
109
+ if (!isPlainObject(evidence)) {
110
+ throw new ConfigValidationError(`${location} requirePrecedent must be an object`);
111
+ }
112
+ const keys = Object.keys(evidence);
113
+ if (keys.length !== 1) {
114
+ throw new ConfigValidationError(`${location} requirePrecedent must have exactly one evidence key`);
115
+ }
116
+ if (keys[0] === 'command') {
117
+ const command = evidence.command;
118
+ if (typeof command !== 'string' || command.length === 0) {
119
+ throw new ConfigValidationError(`${location} requirePrecedent.command must be a non-empty string pattern`);
120
+ }
121
+ rejectUncompilableRegex(command, `${location} requirePrecedent.command`);
122
+ }
123
+ }
124
+ /**
125
+ * Validate the `disciplines` array (COVENANT-10 §4.1). Throws {@link ConfigValidationError}
126
+ * naming the offending entry/key; the validated data passes through verbatim.
127
+ */
128
+ function validateDisciplines(disciplines) {
129
+ if (!Array.isArray(disciplines)) {
130
+ throw new ConfigValidationError('disciplines must be an array');
131
+ }
132
+ const seenIds = new Set();
133
+ disciplines.forEach((entry, index) => {
134
+ if (!isPlainObject(entry)) {
135
+ throw new ConfigValidationError(`disciplines[${index}] must be an object`);
136
+ }
137
+ if (typeof entry.id !== 'string' || entry.id.length === 0) {
138
+ throw new ConfigValidationError(`disciplines[${index}].id must be a non-empty string`);
139
+ }
140
+ const location = `disciplines[${index}] ('${entry.id}')`;
141
+ if (seenIds.has(entry.id)) {
142
+ throw new ConfigValidationError(`${location} duplicates the id of an earlier entry`);
143
+ }
144
+ seenIds.add(entry.id);
145
+ rejectUnknownKeys(entry, DISCIPLINE_KEYS, location);
146
+ if (entry.why !== undefined && typeof entry.why !== 'string') {
147
+ throw new ConfigValidationError(`${location} why must be a string`);
148
+ }
149
+ const predicates = PREDICATE_KEYS.filter((key) => entry[key] !== undefined);
150
+ if (predicates.length !== 1) {
151
+ throw new ConfigValidationError(`${location} must have exactly one predicate key ` +
152
+ `(forbid | immutable | forbidCommand | requirePrecedent)`);
153
+ }
154
+ const predicate = predicates[0];
155
+ if (!SCOPED_PREDICATE_KEYS.has(predicate) &&
156
+ (entry.in !== undefined || entry.except !== undefined)) {
157
+ throw new ConfigValidationError(`${location} allows in/except only on a forbid or requirePrecedent entry`);
158
+ }
159
+ // `when` is the context family's trigger; on any other family it would be dead data
160
+ // implying a trigger that is never applied.
161
+ if (entry.when !== undefined && predicate !== 'requirePrecedent') {
162
+ throw new ConfigValidationError(`${location} allows when only on a requirePrecedent entry`);
163
+ }
164
+ if (entry.in !== undefined && !isValidGlob(entry.in)) {
165
+ throw new ConfigValidationError(`${location} in must be a non-empty glob or glob array`);
166
+ }
167
+ if (entry.except !== undefined && !isValidGlob(entry.except)) {
168
+ throw new ConfigValidationError(`${location} except must be a non-empty glob or glob array`);
169
+ }
170
+ if (predicate === 'forbid') {
171
+ const forbid = entry.forbid;
172
+ if (typeof forbid === 'string') {
173
+ rejectUncompilableRegex(forbid, `${location} forbid`);
174
+ }
175
+ else if (isPlainObject(forbid)) {
176
+ // Only the { added } direction exists before COVENANT-12.
177
+ const keys = Object.keys(forbid);
178
+ if (keys.length !== 1 || keys[0] !== 'added' || typeof forbid.added !== 'string') {
179
+ throw new ConfigValidationError(`${location} forbid object must have exactly one key 'added' with a string pattern`);
180
+ }
181
+ rejectUncompilableRegex(forbid.added, `${location} forbid.added`);
182
+ }
183
+ else {
184
+ throw new ConfigValidationError(`${location} forbid must be a string pattern or an { added } object`);
185
+ }
186
+ }
187
+ else if (predicate === 'immutable') {
188
+ if (!isValidGlob(entry.immutable)) {
189
+ throw new ConfigValidationError(`${location} immutable must be a non-empty glob or glob array`);
190
+ }
191
+ }
192
+ else if (predicate === 'forbidCommand') {
193
+ if (typeof entry.forbidCommand !== 'string') {
194
+ throw new ConfigValidationError(`${location} forbidCommand must be a string pattern`);
195
+ }
196
+ rejectUncompilableRegex(entry.forbidCommand, `${location} forbidCommand`);
197
+ }
198
+ else {
199
+ if (entry.when !== undefined) {
200
+ if (typeof entry.when !== 'string') {
201
+ throw new ConfigValidationError(`${location} when must be a string pattern`);
202
+ }
203
+ if (entry.when.length === 0) {
204
+ // An empty pattern matches at every position, so the trigger would fire on any
205
+ // file that merely grows — reject it like every sibling pattern field.
206
+ throw new ConfigValidationError(`${location} when must be a non-empty string pattern`);
207
+ }
208
+ rejectUncompilableRegex(entry.when, `${location} when`);
209
+ }
210
+ validateRequirePrecedent(entry.requirePrecedent, location);
211
+ }
212
+ });
213
+ return disciplines;
214
+ }
215
+ /**
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.
225
+ */
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;
237
+ if (!isPlainObject(languages) || Object.keys(languages).length === 0) {
238
+ throw new ConfigValidationError('languages must be a non-empty object');
239
+ }
240
+ const resolvedLanguages = {};
241
+ for (const [key, profile] of Object.entries(languages)) {
242
+ if (!isPlainObject(profile)) {
243
+ throw new ConfigValidationError(`languages.${key} must be an object`);
244
+ }
245
+ rejectUnknownKeys(profile, PROFILE_KEYS, `languages.${key}`);
246
+ if (!isValidGlob(profile.productionGlob)) {
247
+ throw new ConfigValidationError(`languages.${key}.productionGlob must be a non-empty string or non-empty array of non-empty strings`);
248
+ }
249
+ if (typeof profile.testCmd === 'function') {
250
+ throw new ConfigValidationError(`languages.${key}.testCmd must be a string template (config-as-data v2) — ` +
251
+ `replace the function with e.g. 'your-runner {scope}'`);
252
+ }
253
+ if (typeof profile.testCmd !== 'string' || profile.testCmd.length === 0) {
254
+ throw new ConfigValidationError(`languages.${key}.testCmd must be a non-empty string template`);
255
+ }
256
+ resolvedLanguages[key] = {
257
+ productionGlob: profile.productionGlob,
258
+ testCmd: compileTestCmd(profile.testCmd),
259
+ };
260
+ }
261
+ if (config.protectedPaths !== undefined && !isStringArray(config.protectedPaths)) {
262
+ throw new ConfigValidationError('protectedPaths must be an array of strings');
263
+ }
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;
278
+ }
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
+ }
292
+ }
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');
302
+ }
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
+ }
308
+ return {
309
+ languages: resolvedLanguages,
310
+ ...(config.protectedPaths !== undefined && { protectedPaths: config.protectedPaths }),
311
+ ...(adapters !== undefined && { adapters }),
312
+ telemetry: {
313
+ logPath: logPath ?? DEFAULT_TELEMETRY_LOG_PATH,
314
+ },
315
+ ...(disciplines !== undefined && { disciplines }),
316
+ ...(witness !== undefined && { witness }),
317
+ };
318
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * fail-policy — the failure-kind → fail-mode policy table (CORE-03).
3
+ *
4
+ * Pure and total: classifying a failure and mapping it to an exit code never
5
+ * performs I/O and never throws. The single source of truth for "which failures
6
+ * block and which pass through" lives here, not scattered across call sites.
7
+ */
8
+ /** How a failure resolves: 'open' passes the call through, 'closed' blocks it. */
9
+ export type FailMode = 'open' | 'closed';
10
+ /**
11
+ * The registered failure kinds (PRD §4.1). Gate-integrity failures
12
+ * (evidence-absence / input-parse / undecidable-structure) fail closed;
13
+ * observability failures fail open so measurement loss never holds work hostage.
14
+ */
15
+ export type FailureKind = 'evidence-absence' | 'input-parse' | 'undecidable-structure' | 'observability';
16
+ /**
17
+ * Resolve a failure kind to its {@link FailMode} via the policy table.
18
+ *
19
+ * fail-closed default (PRD §5.2): any unregistered kind — including '' and
20
+ * prototype-pollution keys — resolves to 'closed'. "Cannot classify" means
21
+ * block. Pure and total (PRD §7): never throws, no I/O, no logging.
22
+ */
23
+ export declare function resolveFailMode(kind: string): FailMode;
24
+ /**
25
+ * Map a {@link FailMode} to the covenant protocol's exit code (PRD §4.2):
26
+ * 'open' → {@link EXIT_UPHOLD}, 'closed' → {@link EXIT_BREAK_BLOCKING}.
27
+ * Reuses CORE-01's constants — no independent numeric literals here.
28
+ */
29
+ export declare function failModeToExitCode(mode: FailMode): 0 | 2;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * fail-policy — the failure-kind → fail-mode policy table (CORE-03).
3
+ *
4
+ * Pure and total: classifying a failure and mapping it to an exit code never
5
+ * performs I/O and never throws. The single source of truth for "which failures
6
+ * block and which pass through" lives here, not scattered across call sites.
7
+ */
8
+ import { EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from './index.js';
9
+ /**
10
+ * Policy table (PRD §4.1). Null-prototype so lookups can never reach
11
+ * Object.prototype members ('__proto__', 'toString', …) — those must resolve
12
+ * to the fail-closed default, not to an inherited function.
13
+ */
14
+ const FAIL_POLICY = Object.assign(Object.create(null), {
15
+ 'evidence-absence': 'closed',
16
+ 'input-parse': 'closed',
17
+ 'undecidable-structure': 'closed',
18
+ observability: 'open',
19
+ });
20
+ /**
21
+ * Resolve a failure kind to its {@link FailMode} via the policy table.
22
+ *
23
+ * fail-closed default (PRD §5.2): any unregistered kind — including '' and
24
+ * prototype-pollution keys — resolves to 'closed'. "Cannot classify" means
25
+ * block. Pure and total (PRD §7): never throws, no I/O, no logging.
26
+ */
27
+ export function resolveFailMode(kind) {
28
+ return FAIL_POLICY[kind] ?? 'closed';
29
+ }
30
+ /**
31
+ * Map a {@link FailMode} to the covenant protocol's exit code (PRD §4.2):
32
+ * 'open' → {@link EXIT_UPHOLD}, 'closed' → {@link EXIT_BREAK_BLOCKING}.
33
+ * Reuses CORE-01's constants — no independent numeric literals here.
34
+ */
35
+ export function failModeToExitCode(mode) {
36
+ return mode === 'open' ? EXIT_UPHOLD : EXIT_BREAK_BLOCKING;
37
+ }