polydeukes 0.0.1 → 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,73 @@
1
+ # polydeukes
2
+
3
+ **한국어** · [English](./README.md)
4
+
5
+ > 스코프 없는 umbrella 패키지입니다. `pdks` CLI 진입점과 config 디스커버리 로더가 살고,
6
+ > 프레임워크의 조각을 저장소가 실제로 돌리는 표면에 맞게 조립하는 유일한 자리입니다.
7
+
8
+ **알파(alpha) 단계입니다.** 이 패키지는 스코프 없는 `polydeukes` 이름을 예약하고, 스코프
9
+ 모듈(`@polydeukes/*`)을 조립할 수 있는 유일한 패키지로 그 위에 섭니다. 다른 모든 의존은 코어를
10
+ 통해서만 단방향으로 흐릅니다.
11
+
12
+ ## 여기 담긴 것
13
+
14
+ - **`loadConfig(rootDir)`.** config 디스커버리입니다. 주어진 루트 바로 아래에서 정확히 하나의
15
+ 데이터 config(`polydeukes.config` 파일의 yaml·yml·json 형태)를 찾아, 안전 스키마로 해석하고
16
+ (설정 데이터는 결코 실행되지 않습니다) 코어의 `defineConfig()`에 검증을 맡깁니다. 모든 실패
17
+ 분기는 예외를 던집니다(throw). 조용한 기본값은 금지이고, 발견된 파일은 자기 자신을 보호
18
+ 표면에 편입시킵니다.
19
+ - **`pdks covenant check`.** `pdks` bin의 첫 실물 서브커맨드입니다(`polydeukes`는 별칭).
20
+ pre-commit 판정 러너로, 스테이징 영역의 변경을 `@polydeukes/adapter-git`이 수집해
21
+ 약속(covenant) 입력 IR로 번역하고, 세션 훅이 띄우는(spawn) 바로 그 판정 본체로 보냅니다.
22
+ 판정기는 하나, 표면은 여럿입니다. 맥락족 규율(discipline, `requirePrecedent`)도 다른 규율과
23
+ 똑같이 조립되지만, 읽을 세션이 없으므로 스킵 등록이 됩니다. 스테이징 변경과 매치하면 자기
24
+ id와 그 변경을 담은 `skipped` 이벤트를 남기고 커밋은 진행됩니다. 여기서 판정하면 걸리는
25
+ 커밋마다 정당하게 통과할 길 없이 막히고, 아예 걸러내면 게이트가 물러섰다는 사실이 가려집니다.
26
+ 세션 표면이 읽을 transcript를 갖지 못했을 때와 같은 처분입니다. 빈 스테이징은 명시적
27
+ 통과이고, config가 없거나 불량이면 닫힌 실패(exit 2)입니다.
28
+ - **`pdks init claude-code`.** 세션 표면 설치기입니다. 아무것도 쓰기 전에 대상 프로젝트에서
29
+ `polydeukes`가 해소되는지부터 증명하고, 그다음 모든 배포 경로가 공유하는 것(데이터 config와
30
+ `.polydeukes/` 무시 줄)과 이 경로가 지는 것(위임자 훅, 그 파일이 이미 담고 있던 것에 병합되는
31
+ `.claude/settings.json` 등록, 그리고 AI 파트너를 `pdks docs`로 보내는 범위 한정 규율 파일)을
32
+ 만듭니다. 기존 산출물은 덮어쓰지 않습니다. 이미 있는 것은 보고하고 그대로 두므로 재실행은
33
+ 아무 일도 하지 않고, 사용자가 고친 내용이 살아남습니다. 생성된 config는 해소 경로를 보호
34
+ 목록에 담고 증인(witness) 블록을 함께 갖습니다. 밸브가 없으면 첫 차단이 곧 프로젝트 정지인데,
35
+ 방금 등록한 훅 자신이 보호 대상이기 때문입니다. config 철자 둘이 공존하는 트리, 파싱되지 않는
36
+ settings 파일, 해소되지 않는 패키지는 전부 선행 조건 실패입니다. 반쯤 배선된 트리를 남기는
37
+ 대신 파일을 하나도 만들지 않습니다.
38
+ - **`pdks docs [topic]`.** 오프라인 문서 열람기입니다. 영어 가이드와 레퍼런스 층이 빌드 시점에
39
+ `dist/docs`로 복사되므로, 소비자의 AI 파트너는 검색 엔진이 색인한 아무 판본이 아니라 판정을
40
+ 수행하는 코드와 함께 실린 문서를 읽습니다. 인자 없이 부르면 토픽 다섯을 열거하는데, 에이전트가
41
+ 무엇을 물을 수 있는지 아는 경로가 그 목록뿐입니다. 토픽을 주면 그 절을 원문 그대로 돌려주고
42
+ 이어서 읽을 레퍼런스를 한 줄 덧붙입니다. 질의 정의역은 그 다섯 이름뿐입니다. 모르는 토픽, 없는
43
+ 동봉 문서, 문서가 더는 담지 않는 표제는 각각 무엇이 없었는지 stderr에 이름을 적고 exit 2로
44
+ 끝나며 stdout은 0바이트로 둡니다. 절반만 쓰인 답은 에이전트가 그것을 문서로 읽고 그대로
45
+ 인용하므로, 그런 상태를 만드는 경로를 두지 않습니다.
46
+ - **커밋 표면 증인(witness) 밸브.** `block` 수위(기본값)에서 스테이징 영역의 변경이 실제로
47
+ 약속을 깨면 러너가 `/dev/tty`에서 증인 토큰 전문을 1회 묻습니다(부분 문자열은 거부합니다).
48
+ 프롬프트는 깨진 등록과 걸린 항목, 그리고 이 한 번의 답이 커밋 전체를 덮는다는 사실을 적습니다.
49
+ 깨끗한 커밋은 프롬프트를 보지 않습니다. TTY가 없으면, 즉 CI나 에이전트가 띄운 `git commit`
50
+ 이면 프롬프트도 통과할 길도 없습니다. 밸브는 터미널 앞의 인간에게만 닿고, 어떤 상태도 남기지
51
+ 않으며, 증언으로 통과한 판정은 모두 `witnessed`로 기록됩니다. 조용한 우회는 없습니다.
52
+ - **시행 수위.** git 어댑터의 네임스페이스 설정 `adapters.git.enforce: block | advise`가 커밋
53
+ 표면 판정의 처분을 고릅니다. `advise`에서는 밸브 자체가 조립되지 않습니다. 판정은 `advised`
54
+ 이벤트로 기록되고, stderr에 권고 한 줄이 남으며, 커밋은 진행됩니다. 차단하는 대신 측정하는
55
+ 백스톱입니다. 완화되는 것은 판정뿐이라, 판정 자체가 불가능한 실행(설정 없음·무효, 판정 본체
56
+ 해석 불가)은 어느 수위에서든 exit 2로 닫힙니다.
57
+
58
+ ## 전체 지도
59
+
60
+ | 모듈 | 역할 |
61
+ |---|---|
62
+ | `@polydeukes/core` | 약속(covenant) 프로토콜, config 스키마, ROI 텔레메트리, 대화 기록 이음새 |
63
+ | `@polydeukes/covenant` | 디스패처, 판정 본체, Bash 분석, 규율(discipline) 라이브러리 |
64
+ | `@polydeukes/adapter-claude-code` | 세션 표면. PreToolUse 페이로드를 약속(covenant) 입력 IR로 번역 |
65
+ | `@polydeukes/adapter-git` | 커밋 표면. `staged diff`를 약속(covenant) 입력 IR로 번역 |
66
+ | `@polydeukes/ledger` · `@polydeukes/memory` · `@polydeukes/verify` | 청사진 단계 |
67
+
68
+ 아키텍처 청사진과 설계 근거는
69
+ [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
70
+
71
+ ## 라이선스
72
+
73
+ MIT
package/README.md CHANGED
@@ -1,22 +1,77 @@
1
1
  # polydeukes
2
2
 
3
- > A harness framework for developing alongside an AI coding partner.
3
+ **English** · [한국어](https://github.com/huskyhoochu/polydeukes/blob/main/packages/polydeukes/README.ko.md)
4
4
 
5
- **Pre-alpha — design stage.** This package currently reserves the name on npm and exposes only version metadata. The core, guard, ledger, kb, and verify modules are in design.
5
+ > The unscoped umbrella: the `pdks` CLI entry point and the config discovery loader — the one
6
+ > place where the framework's pieces are assembled for the surfaces a repository actually runs.
6
7
 
7
- Polydeukes externalizes the discipline a developer applies to themselves — test-first, verify-before-commit, record decisions, don't repeat mistakes — into deterministic machinery shared with an AI partner, rather than prompt-level requests. The framing is partnership, not control.
8
+ **Alpha.** This package reserves the unscoped `polydeukes` name and sits above the scoped
9
+ `@polydeukes/*` modules as the only one allowed to assemble them — every other dependency stays
10
+ one-way, through the core alone.
8
11
 
9
- See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture blueprint and design rationale.
12
+ ## What lives here
10
13
 
11
- ## Planned packages
14
+ - **`loadConfig(rootDir)`** — config discovery. Exactly one root data config (a
15
+ `polydeukes.config` file in yaml, yml, or json form) directly under the given root, parsed with
16
+ a safe schema (config data is never executable) and validated by the core's `defineConfig()`.
17
+ Every failure branch throws — silent defaults are forbidden — and the discovered file attaches
18
+ itself to its own protection surface.
19
+ - **`pdks covenant check`** — the first real subcommand of the `pdks` bin (`polydeukes` is an
20
+ alias). A pre-commit judgment runner: staged changes are collected by `@polydeukes/adapter-git`,
21
+ translated into the covenant input IR, and dispatched through the very judge bodies the session
22
+ hook spawns — one judge, every surface. Context-family disciplines (`requirePrecedent`) assemble
23
+ here like any other, but with no session to read they become skip registrations: when one
24
+ matches a staged change it records a `skipped` event carrying its id and that change, and the
25
+ commit proceeds. Judging them would block every matching commit with no legitimate pass path;
26
+ filtering them out would hide that a gate stood down. It is the same disposition the session
27
+ surface uses when it has no transcript. An empty staging area is an explicit pass; a missing or
28
+ invalid config fails closed.
29
+ - **`pdks init claude-code`** — the session-surface installer. It proves `polydeukes` resolves
30
+ from the target project before writing anything, then creates what every distribution path
31
+ shares (the data config and its `.polydeukes/` ignore line) and what this path owns (a delegator
32
+ hook, its `.claude/settings.json` registration merged into whatever that file already carries,
33
+ and a scoped discipline file pointing an AI partner at `pdks docs`). Nothing existing is
34
+ overwritten: an artifact already there is reported and left alone, so a re-run is a no-op and a
35
+ consumer's edits survive. The generated config carries the resolution paths on its protection
36
+ list and a witness block — without the valve the first block would freeze the project, since the
37
+ hook it just registered is itself protected. Two coexisting config spellings, an unparseable
38
+ settings file, and a package that cannot be resolved are all precondition failures: each leaves
39
+ zero files rather than a half-wired tree.
40
+ - **`pdks docs [topic]`** — the offline documentation reader. The English guides and the reference
41
+ layer are copied into `dist/docs` at build time, so a consumer's AI partner reads the
42
+ documentation that shipped with the code doing the judging instead of whichever release a search
43
+ engine indexed. With no argument it lists the five topics — that listing is how an agent
44
+ discovers what it may ask at all; with one it returns that topic's section verbatim plus the
45
+ reference to read next. The query domain is those five names and nothing else: an unknown topic,
46
+ a bundled document that is missing, and a heading a document no longer carries each name what
47
+ was missing on stderr and exit 2, leaving stdout at zero bytes. A partially written answer would
48
+ be read as the document and quoted onward, so no path produces one.
49
+ - **The commit-surface witness valve** — at the `block` level (the default), when a staged change
50
+ actually breaks a covenant, the runner prompts once on `/dev/tty` for the full witness token (a
51
+ substring is refused), naming the broken registration, the matched entry, and the commit-wide
52
+ reach of the one answer. A clean commit never prompts. No TTY — CI, an agent-spawned
53
+ `git commit` — means no prompt and no way through: the valve is reachable only by a human at a
54
+ terminal, and nothing is ever persisted. Every witnessed pass is measured as `witnessed`, never
55
+ silent.
56
+ - **The enforcement level** — the git adapter's namespace setting
57
+ `adapters.git.enforce: block | advise` selects what a commit-surface verdict does. Under
58
+ `advise` the valve is structurally absent: a verdict is recorded as an `advised` event, one
59
+ advisory line lands on stderr, and the commit proceeds — a backstop that measures instead of
60
+ blocking. Only the verdict is relaxed: a run that cannot judge (missing or invalid config, an
61
+ unresolvable judge body) fails closed at exit 2 at either level.
12
62
 
13
- | Package | Role |
14
- |---------|------|
15
- | `@polydeukes/core` | Guard protocol, config loader, transcript interface |
16
- | `@polydeukes/guard` | Deterministic PreToolUse hooks + self-mod meta-guard |
17
- | `@polydeukes/ledger` | Work tracking; completion authority moves from "I'm done" to "the actions passed" |
18
- | `@polydeukes/kb` | Local SQLite + FTS5 knowledge store |
19
- | `@polydeukes/verify` | Multi-agent adversarial verification |
63
+ ## The wider map
64
+
65
+ | Module | Role |
66
+ |---|---|
67
+ | `@polydeukes/core` | Covenant protocol, config schema, ROI telemetry, transcript seam |
68
+ | `@polydeukes/covenant` | Dispatcher, judge bodies, Bash analysis, discipline library |
69
+ | `@polydeukes/adapter-claude-code` | Session surface — PreToolUse payloads → covenant input IR |
70
+ | `@polydeukes/adapter-git` | Commit surface — staged diffs → covenant input IR |
71
+ | `@polydeukes/ledger` · `@polydeukes/memory` · `@polydeukes/verify` | Blueprint stage |
72
+
73
+ See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture
74
+ blueprint and design rationale.
20
75
 
21
76
  ## License
22
77
 
package/dist/bin.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `pdks` / `polydeukes` — the umbrella bin (ADAPTER-git §4.3).
4
+ *
5
+ * A thin argv shim over {@link runCovenantCheck}: `covenant check` is the ONLY
6
+ * recognized invocation (the wider CLI skeleton is a post-release increment). Anything
7
+ * else prints usage and exits 2 — an unknown argument must never pass silently
8
+ * (fail-closed, the same posture as an unjudgeable payload).
9
+ *
10
+ * The real TTY is wired HERE, not in the library: the runner receives an injectable
11
+ * seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
12
+ * CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
13
+ * the valve is structurally unreachable (AC-3 human-only arming).
14
+ */
15
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `pdks` / `polydeukes` — the umbrella bin (ADAPTER-git §4.3).
4
+ *
5
+ * A thin argv shim over {@link runCovenantCheck}: `covenant check` is the ONLY
6
+ * recognized invocation (the wider CLI skeleton is a post-release increment). Anything
7
+ * else prints usage and exits 2 — an unknown argument must never pass silently
8
+ * (fail-closed, the same posture as an unjudgeable payload).
9
+ *
10
+ * The real TTY is wired HERE, not in the library: the runner receives an injectable
11
+ * seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
12
+ * CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
13
+ * the valve is structurally unreachable (AC-3 human-only arming).
14
+ */
15
+ import { closeSync, openSync, readSync, writeSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ /**
19
+ * Bind the TTY prompt seam to /dev/tty, or undefined when no terminal exists. The runner
20
+ * composes the prompt text (it is the side that knows what broke); this shim only writes
21
+ * it and reads the line back.
22
+ */
23
+ function openTtyPrompt() {
24
+ let fd;
25
+ try {
26
+ fd = openSync('/dev/tty', 'r+');
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ return (prompt) => {
32
+ try {
33
+ writeSync(fd, prompt);
34
+ const buffer = Buffer.alloc(4096);
35
+ const bytes = readSync(fd, buffer, 0, buffer.length, null);
36
+ return buffer
37
+ .subarray(0, Math.max(bytes, 0))
38
+ .toString('utf-8')
39
+ .replace(/\r?\n$/, '');
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ finally {
45
+ try {
46
+ closeSync(fd);
47
+ }
48
+ catch {
49
+ // A second consultation would land on an already-closed fd; an EBADF thrown
50
+ // from this finally would override the `return null` above and escape the
51
+ // seam (PR #41 review). The valve caches its verdict, so this is defensive.
52
+ }
53
+ }
54
+ };
55
+ }
56
+ const args = process.argv.slice(2);
57
+ if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
58
+ try {
59
+ // Imported inside the try, not above it: ESM imports are eager, so the installer stays
60
+ // off `covenant check`'s load path (lefthook spawns that on every commit and it must not
61
+ // pull the session adapter in — PR #46 review). A rejected import outside the try would
62
+ // reach node's unhandled-rejection exit 1, the exact crash this bin refuses to make.
63
+ const { initClaudeCode } = await import('./init-claude-code.js');
64
+ const { created, skipped } = initClaudeCode({ projectRoot: process.cwd() });
65
+ for (const path of created) {
66
+ process.stdout.write(`created ${path}\n`);
67
+ }
68
+ for (const path of skipped) {
69
+ process.stdout.write(`skipped ${path} (already present)\n`);
70
+ }
71
+ process.exit(0);
72
+ }
73
+ catch (error) {
74
+ // A precondition failure leaves zero files (DIST-02 §5-d invariant 2); the message
75
+ // names what the user has to do before running this again.
76
+ process.stderr.write(`pdks init claude-code failed: ${error instanceof Error ? error.message : String(error)}\n`);
77
+ process.exit(2);
78
+ }
79
+ }
80
+ if (args[0] === 'docs' && args.length <= 2) {
81
+ try {
82
+ // Imported inside the try for the same reason `init` is: the query core and the
83
+ // markdown behind it have no business on `covenant check`'s load path, which lefthook
84
+ // spawns on every commit.
85
+ const { queryDocs } = await import('./docs-query.js');
86
+ // The bundle ships beside this file, so the docs root comes from the module's own
87
+ // location — never from the working directory, which is whatever shell invoked us.
88
+ const docsRoot = join(dirname(fileURLToPath(import.meta.url)), 'docs');
89
+ const { text } = queryDocs({ docsRoot, topic: args[1] });
90
+ // A reader that goes away mid-write (a killed pager, `grep -q`, a caller closing its
91
+ // capture) makes the stream emit `error` — an EventEmitter event, so it fires outside
92
+ // the frame this try guards and would reach node's default handler: exit 1 and a raw
93
+ // stack trace, the one disposition this bin never produces. The docs answer is not a
94
+ // verdict, so a reader that stopped listening is not something to report; end at the
95
+ // same code an unanswerable query uses.
96
+ process.stdout.on('error', () => process.exit(2));
97
+ // stdout is a pipe whenever this is captured or redirected, and a piped write is
98
+ // asynchronous — exiting on the next line would discard whatever is still buffered.
99
+ // The whole answer IS the deliverable here (a truncated document is one an agent
100
+ // quotes onward as if complete), so the exit waits for the flush. Awaiting rather
101
+ // than exiting from the callback also keeps this branch from falling through into
102
+ // the covenant runner below while the write drains.
103
+ await new Promise((settle) => {
104
+ process.stdout.write(text, () => settle());
105
+ });
106
+ process.exit(0);
107
+ }
108
+ catch (error) {
109
+ // stdout stays at zero bytes on this path (DOCS-02 §3-b): what cannot be answered is
110
+ // never answered halfway.
111
+ process.stderr.write(`pdks docs: ${error instanceof Error ? error.message : String(error)}\n`);
112
+ process.exit(2);
113
+ }
114
+ }
115
+ if (args.length !== 2 || args[0] !== 'covenant' || args[1] !== 'check') {
116
+ process.stderr.write('usage: pdks covenant check | pdks init claude-code | pdks docs [topic]\n');
117
+ process.exit(2);
118
+ }
119
+ try {
120
+ // Loaded here rather than at the top of the file. This runner statically pulls in the
121
+ // git adapter, the core, and the judge, so a top-level import made every subcommand
122
+ // wait on all three resolving — and `docs` is the one that has to answer in a tree
123
+ // where they do not, since a package installed but never built is exactly the state
124
+ // `pdks docs install` is asked about. The catch below already answers for whatever
125
+ // this import cannot do, at the same exit 2 it answers everything else with.
126
+ const { runCovenantCheck } = await import('./covenant-check.js');
127
+ const { exitCode } = await runCovenantCheck({
128
+ repoRoot: process.cwd(),
129
+ ttyPrompt: openTtyPrompt(),
130
+ });
131
+ process.exit(exitCode);
132
+ }
133
+ catch (error) {
134
+ // Any failure the runner did not already translate is unjudgeable — block, never
135
+ // crash into node's exit 1 (the session hook's catch-all posture, AC-7).
136
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
137
+ process.exit(2);
138
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `runClaudeCodeHook` — the assembled session-surface judgment runner (DIST-01 §3-c).
3
+ *
4
+ * The session counterpart of {@link runCovenantCheck}, and the one place where the Claude
5
+ * Code adapter (tool vocabulary, up-translation) and the covenant package (dispatcher +
6
+ * judge bodies) meet. Packages stay one-way — each depends only on core — so their
7
+ * composition lives here, in the umbrella, and the repository's PreToolUse hook shrinks to
8
+ * a delegator that calls this function. That is what makes the session surface installable:
9
+ * a consumer registers a hook that resolves this package instead of copying assembly.
10
+ *
11
+ * Wiring shape: COVENANT-03 §4.4 + COVENANT-04d §4.5 registrations consumed through
12
+ * ADAPTER-03 §4.1 `runAdapterPath`, with `dispatchCovenants` bound to the injected dispatch
13
+ * seam. The protection-policy data (protectedPaths / disciplines / witness) is read from the
14
+ * root data config through {@link loadConfig} (CONFIG-03), which also attaches the config
15
+ * file to its own surface.
16
+ *
17
+ * The valve is the TTL witness (COVENANT-06, moved behind the verdict by COVENANT-17)
18
+ * judged over the JSONL transcript provider (ADAPTER-04). The judge body always spawns, and
19
+ * only an outcome that translated to blocked consults the witness — `witnessed` rows are
20
+ * would-block only. Its defence is provenance rather than secrecy: only a real human
21
+ * utterance carries the transcript marking `findUserMessages()` admits.
22
+ *
23
+ * fail-closed: ANY failure — an unbuilt judge body, an unreadable stdin, a missing or
24
+ * invalid config file — resolves to `{ exitCode: 2 }` with one `blocked` record under the
25
+ * `hook` label. Nothing throws: an uncaught rejection would exit the delegator non-blocking,
26
+ * the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
27
+ * mentions no protected path, so it is never blocked).
28
+ */
29
+ /** `runClaudeCodeHook` input (DIST-01 §3-c) — the `CovenantCheckSpec` shape, session side. */
30
+ export type ClaudeCodeHookSpec = {
31
+ /** Repository root — config discovery and discipline glob scoping both anchor here. */
32
+ repoRoot: string;
33
+ /** One raw PreToolUse payload. ABSENT means read fd 0 (the hook's real stdin). */
34
+ rawPayload?: string;
35
+ /** Overrides the config's telemetry log path (tests and assembly injection). */
36
+ telemetryPath?: string;
37
+ /** Overrides the resolved covenant dist directory (tests and assembly injection). */
38
+ covenantDist?: string;
39
+ };
40
+ /**
41
+ * Judge one declared tool call before it runs (DIST-01 §3-c). Async because the dispatcher
42
+ * spawns covenant bodies (CORE-01) — a synchronous runner would mean reimplementing the
43
+ * judge, which the single-dispatcher principle forbids.
44
+ */
45
+ export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<{
46
+ exitCode: 0 | 2;
47
+ }>;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `runClaudeCodeHook` — the assembled session-surface judgment runner (DIST-01 §3-c).
3
+ *
4
+ * The session counterpart of {@link runCovenantCheck}, and the one place where the Claude
5
+ * Code adapter (tool vocabulary, up-translation) and the covenant package (dispatcher +
6
+ * judge bodies) meet. Packages stay one-way — each depends only on core — so their
7
+ * composition lives here, in the umbrella, and the repository's PreToolUse hook shrinks to
8
+ * a delegator that calls this function. That is what makes the session surface installable:
9
+ * a consumer registers a hook that resolves this package instead of copying assembly.
10
+ *
11
+ * Wiring shape: COVENANT-03 §4.4 + COVENANT-04d §4.5 registrations consumed through
12
+ * ADAPTER-03 §4.1 `runAdapterPath`, with `dispatchCovenants` bound to the injected dispatch
13
+ * seam. The protection-policy data (protectedPaths / disciplines / witness) is read from the
14
+ * root data config through {@link loadConfig} (CONFIG-03), which also attaches the config
15
+ * file to its own surface.
16
+ *
17
+ * The valve is the TTL witness (COVENANT-06, moved behind the verdict by COVENANT-17)
18
+ * judged over the JSONL transcript provider (ADAPTER-04). The judge body always spawns, and
19
+ * only an outcome that translated to blocked consults the witness — `witnessed` rows are
20
+ * would-block only. Its defence is provenance rather than secrecy: only a real human
21
+ * utterance carries the transcript marking `findUserMessages()` admits.
22
+ *
23
+ * fail-closed: ANY failure — an unbuilt judge body, an unreadable stdin, a missing or
24
+ * invalid config file — resolves to `{ exitCode: 2 }` with one `blocked` record under the
25
+ * `hook` label. Nothing throws: an uncaught rejection would exit the delegator non-blocking,
26
+ * the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
27
+ * mentions no protected path, so it is never blocked).
28
+ */
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { createRequire } from 'node:module';
31
+ import { homedir } from 'node:os';
32
+ import { dirname, join, resolve } from 'node:path';
33
+ import { COMMAND_ARGS, evaluatePrecedent, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
34
+ import { appendRecordFailOpen, normalizeProtectedPaths } from '@polydeukes/core';
35
+ import { compileDisciplineRegistrations, dispatchCovenants, transcriptModRegistration, ttlWitness, } from '@polydeukes/covenant';
36
+ import { loadConfig } from './load-config.js';
37
+ /**
38
+ * Compose a judge body path and prove it exists (CONFIG-06b §4.2). A body module that was
39
+ * never built makes node exit 1 — the same code a real break verdict returns — so nothing
40
+ * downstream can separate an unjudgeable run from a judged one. The proof therefore belongs
41
+ * to the act of composing the path, and a body this assembly composes no path for is never
42
+ * proven: the throw lands in the fail-closed catch below, one blocked record and exit 2.
43
+ */
44
+ function provenBodyPath(distDir, fileName) {
45
+ const modulePath = join(distDir, fileName);
46
+ if (!existsSync(modulePath)) {
47
+ throw new Error(`judge body ${modulePath} is missing — run 'pnpm build' to rebuild it`);
48
+ }
49
+ return modulePath;
50
+ }
51
+ /**
52
+ * Judge one declared tool call before it runs (DIST-01 §3-c). Async because the dispatcher
53
+ * spawns covenant bodies (CORE-01) — a synchronous runner would mean reimplementing the
54
+ * judge, which the single-dispatcher principle forbids.
55
+ */
56
+ export async function runClaudeCodeHook(spec) {
57
+ // Env-first telemetry precedence (E2E contract), settled BEFORE any failure branch: a
58
+ // config that never loads still has somewhere to write its one blocked row. The config
59
+ // value applies after the load succeeds.
60
+ //
61
+ // Computed INSIDE the try even though it must run first, because `join` throws on a
62
+ // non-string repoRoot and this function's contract is that nothing escapes it — a rejection
63
+ // would exit a delegator non-blocking, which is the cheapest bypass there is. A throw here
64
+ // leaves `telemetryPath` undefined, which the catch tolerates: there is no root to write a
65
+ // row under anyway (PR #46 review).
66
+ let telemetryPath;
67
+ try {
68
+ const envTelemetryPath = process.env.POLYDEUKES_TELEMETRY_PATH;
69
+ telemetryPath =
70
+ spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot, '.polydeukes', 'roi.log');
71
+ // Discovery + parse + validation are the loader's job; a throw here (absent, ambiguous,
72
+ // unparseable, or invalid config) falls into the fail-closed catch.
73
+ const { config } = loadConfig(spec.repoRoot);
74
+ telemetryPath =
75
+ spec.telemetryPath ?? envTelemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
76
+ // Settled for the rest of the happy path. The `let` above exists so the catch can still
77
+ // record when a failure lands before this point; a closure cannot narrow it, so the
78
+ // dispatch seam below takes this const instead.
79
+ const logPath = telemetryPath;
80
+ const rawPayload = spec.rawPayload ?? readFileSync(0, 'utf-8');
81
+ // The transcript path travels in the raw payload only — up-translation drops it, so the
82
+ // adapter reads it from the string. Every failure narrows to `undefined`, which leaves
83
+ // the dispatcher on its `noopTranscript` default: lost evidence closes the valve rather
84
+ // than opening it (ADAPTER-04 §4.4).
85
+ const transcriptPath = transcriptPathFromPayload(rawPayload);
86
+ const transcript = transcriptPath === undefined ? undefined : transcriptFromJsonlFile(transcriptPath);
87
+ // The live transcript is the evidence channel the context family reads AND the one the
88
+ // witness reads, so erasing or forging it disables every context discipline while
89
+ // opening or shutting the human valve on the same file. It lives outside the repository,
90
+ // so no config `protectedPaths` entry can reach it — and since COVENANT-07c it does NOT
91
+ // join this list either. A file deep under HOME makes HOME itself a protected ANCESTOR,
92
+ // which measured as the COVENANT-13 over-block: `cd /home/<user>` refused for two weeks,
93
+ // and the 07b attempt to register the home spellings alongside only widened that to
94
+ // `echo $HOME` and every edit whose content carried a bare `~`. Assembly knows the path
95
+ // AND the home value, so assembly registers a dedicated `matches` predicate over that
96
+ // ONE file instead (transcript-mod, below): equality-only — never an ancestor — with the
97
+ // `~`/`$HOME`/`${HOME}`/`~<user>` spellings closed as data, reads absolved by the
98
+ // read-only allowlist, and ancestor destruction outside the repository declared out of
99
+ // observation scope (07c §2: the agent's own deny policy owns what no repo-scoped judge
100
+ // can). The witness valve applies to it like any other registration.
101
+ const protectedPaths = normalizeProtectedPaths({
102
+ protectedPaths: config.protectedPaths ?? [],
103
+ });
104
+ // One witness predicate shared by every registration: a witness is a session-wide
105
+ // permission the human granted, not a per-covenant one. Absent `witness` config leaves
106
+ // this undefined, and no verdict can be witnessed open at all. The predicate receives
107
+ // the transcript as its second argument from the dispatcher (CORE-04 seam), which is why
108
+ // the transcript is injected below rather than captured here.
109
+ const witness = config.witness === undefined
110
+ ? undefined
111
+ : ttlWitness({
112
+ token: config.witness.token,
113
+ // Minutes are the human-facing unit in config; the predicate takes milliseconds.
114
+ // Core passes the value through verbatim, so the conversion belongs to assembly.
115
+ ttlMs: config.witness.ttlMinutes * 60_000,
116
+ });
117
+ // The judge bodies are the covenant package's dist executables — resolved through the
118
+ // real package (never a test alias), so the session surface spawns the same judges the
119
+ // commit surface does. An injected directory overrides that resolution: `createRequire`
120
+ // is real Node resolution and always lands on the real build, which no fixture tree can
121
+ // take a body away from.
122
+ const covenantDist = spec.covenantDist ?? dirname(createRequire(import.meta.url).resolve('@polydeukes/covenant'));
123
+ // Only the two unconditional registrations compose their paths here. The transcript-mod
124
+ // and discipline bodies are composed inside the conditions that decide whether their
125
+ // registrations exist at all — proving a body this run will never spawn would close a
126
+ // call over a file it was never going to use (CONFIG-06b §4.2 corollary).
127
+ const selfModBody = provenBodyPath(covenantDist, 'self-mod-body.js');
128
+ const shellModBody = provenBodyPath(covenantDist, 'shell-mod-body.js');
129
+ const disciplines = config.disciplines ?? [];
130
+ const pathArgs = protectedPaths.flatMap((path) => ['--protected-path', path]);
131
+ const registrations = [
132
+ {
133
+ label: 'self-mod',
134
+ protectedPaths,
135
+ body: {
136
+ command: process.execPath,
137
+ args: [
138
+ selfModBody,
139
+ ...pathArgs,
140
+ ...MUTATING_TOOLS.flatMap((tool) => ['--mutating-tool', tool]),
141
+ ],
142
+ },
143
+ witness,
144
+ },
145
+ {
146
+ label: 'shell-mod',
147
+ protectedPaths,
148
+ body: {
149
+ command: process.execPath,
150
+ args: [
151
+ shellModBody,
152
+ ...pathArgs,
153
+ ...SHELL_TOOLS.flatMap((tool) => ['--shell-tool', tool]),
154
+ ...COMMAND_ARGS.flatMap((arg) => ['--command-arg', arg]),
155
+ ],
156
+ },
157
+ witness,
158
+ },
159
+ // The transcript's own registration (COVENANT-07c). Routing is the matches predicate,
160
+ // never path mention, so the home directory cannot become a protected ancestor. No
161
+ // transcript in the payload means nothing to protect — the valve and the context
162
+ // family already forfeited on the same absence.
163
+ ...(transcriptPath === undefined
164
+ ? []
165
+ : [
166
+ transcriptModRegistration({
167
+ transcriptPath,
168
+ // The env value first, since that is what the judged shell expands `~` and
169
+ // `$HOME` from. `homedir()` reads the same passwd entry bash falls back to when
170
+ // HOME is unset, so a hook spawned without an environment (a service manager,
171
+ // `env -i`) keeps judging the home spellings instead of silently going
172
+ // absolute-only — an inert spelling closure looks identical to a passing call.
173
+ home: process.env.HOME ?? homedir(),
174
+ bodyCommand: process.execPath,
175
+ bodyModulePath: provenBodyPath(covenantDist, 'transcript-mod-body.js'),
176
+ shellTools: SHELL_TOOLS,
177
+ commandArgs: COMMAND_ARGS,
178
+ mutatingTools: MUTATING_TOOLS,
179
+ witness,
180
+ }),
181
+ ]),
182
+ // The body path is passed as a thunk, so the proof fires only where the compiler
183
+ // actually composes a body. Entry count cannot stand in for that: an entry may compile
184
+ // to a body-less skip (a `requirePrecedent` one whenever no transcript came with the
185
+ // payload), and the compiler appends the body-less `shell-unjudgeable` backstop even
186
+ // for zero entries — gating the call itself would drop that record and turn an
187
+ // uncomputable shell write back into a silent pass, undoing COVENANT-10b.
188
+ ...compileDisciplineRegistrations({
189
+ disciplines,
190
+ rootDir: spec.repoRoot,
191
+ bodyCommand: process.execPath,
192
+ bodyModulePath: () => provenBodyPath(covenantDist, 'discipline-body.js'),
193
+ shellTools: SHELL_TOOLS,
194
+ commandArgs: COMMAND_ARGS,
195
+ witness,
196
+ // Context-family evidence is evaluated here, at assembly: a spawned body cannot hold
197
+ // a transcript, and passing a path would leak JSONL knowledge into covenant
198
+ // (COVENANT-13 §4.4). The adapter brings the evaluator for its own `subagent`/`tool`
199
+ // vocabulary; core owns `command`, which the compiler judges directly.
200
+ transcript,
201
+ evaluatePrecedent,
202
+ }),
203
+ ];
204
+ // This assembly is versioned with the umbrella; the covenant dist it composes against is
205
+ // resolved from the installation graph, so a workspace nobody rebuilt pairs a new
206
+ // assembly with an old compiler — and an old compiler stores the body-path thunk itself
207
+ // where a string belongs. `spawn` does not reject a non-string argv entry — it
208
+ // stringifies it — so the judge would be spawned on the thunk's own source text, exit 1,
209
+ // and be recorded as a VERDICT under a discipline's label. Assert the shape and let the
210
+ // fail-closed catch answer instead.
211
+ for (const registration of registrations) {
212
+ if (registration.body !== undefined && typeof registration.body.args?.[0] !== 'string') {
213
+ throw new Error(`covenant dist predates the lazy body-path convention (registration '${registration.label}') — run 'pnpm build'`);
214
+ }
215
+ }
216
+ return await runAdapterPath({
217
+ rawPayload,
218
+ telemetryPath: logPath,
219
+ dispatch: (stdinPayload) => dispatchCovenants({ stdinPayload, registrations, telemetryPath: logPath, transcript }),
220
+ });
221
+ }
222
+ catch (error) {
223
+ process.stderr.write(`covenant hook failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
224
+ // Honor the one-call-one-record invariant with a blocked record under the assembly's own
225
+ // label (COVENANT-07 §4.3) — never a judge's, since no judge answered. `undefined` means
226
+ // the failure landed before a path could even be composed (a non-string repoRoot), where
227
+ // there is nowhere to write and nothing to attribute the row to.
228
+ if (telemetryPath !== undefined) {
229
+ appendRecordFailOpen(telemetryPath, { event: 'blocked', label: 'hook', subject: '-' });
230
+ }
231
+ return { exitCode: 2 };
232
+ }
233
+ }