@polydeukes/adapter-claude-code 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,22 @@
1
+ # @polydeukes/adapter-claude-code
2
+
3
+ **한국어** · [English](./README.md)
4
+
5
+ > Claude Code의 어휘가 번역되어 사라지는 경계입니다. PreToolUse 훅 페이로드는 코어에 닿기 전에 에이전트 중립적인 약속(covenant) 입력 IR로 바뀝니다.
6
+
7
+ **알파(alpha) 단계입니다.** 에이전트와 도구의 리터럴은 의도적으로 이 패키지 안에만 삽니다. 이 패키지가 존재하는 이유가 그 리터럴이 코어에 닿지 않게 하는 것이고, 그래서 코어의 에이전트 중립성은 구호가 아니라 시험 가능한 주장으로 남습니다.
8
+
9
+ ## 여기 담긴 것
10
+
11
+ - **페이로드 번역.** 날것의 PreToolUse 페이로드가 `CovenantInput`(도구 호출과 서브에이전트 스폰)으로 바뀝니다. 서브에이전트 유형을 실은 `Task` 호출은 스폰으로 대응되고, 분류할 수 없는 페이로드는 번역 실패입니다. 실패는 추측으로 대체되는 대신 차단됩니다(fail-closed).
12
+ - **가상 사후 상태(virtual post-state).** `Edit`·`Write`·`MultiEdit`가 적용되면 파일이 어떤 내용이 될지를 디스크를 건드리지 않고 계산합니다. 내용을 보는 약속은 지금의 파일이 아니라 제안된 결과를 판정합니다. 여러 편집의 순차 적용과 파일 생성 관례까지 계산에 들어갑니다.
13
+ - **파일 변경 증거.** `collectFileChanges`가 디스크의 편집 전 상태와 가상 사후 상태를 짝지어 판별 유니온(discriminated union) 증거(편집 전 상태가 없으면 `create`, 있으면 `modify`)로 만들고, 변이 도구 호출 자신의 `fileChange` 필드에 싣습니다. 규율(discipline) 판정이 소비하는 증거가 이것입니다. 사후 상태가 성립하지 않으면 아무것도 내지 않고(같은 편집은 실제 도구도 거부합니다), 비변이 호출에 증거를 날조하지 않습니다.
14
+ - **대화 기록 공급자.** `transcriptFromJsonl`과 `transcriptFromJsonlFile`이 세션 JSONL 파일을 `CanonicalTranscript`로 바꿉니다. 시간제 증인(TTL witness)의 실제 데이터 원천이며, 사람이 직접 친 것으로 확인된 메시지만 받아들이므로 AI가 자기 증언을 합성할 길이 없습니다. `findToolCalls`에도 답해 `tool_use` 블록에서 도구 호출을 뽑아냅니다. 호출의 `input`이 평면 객체가 아니면 블록은 남기고 `args`만 비우는데, 호출이 있었다는 사실 자체가 증거이기 때문입니다. 읽기에 실패하면 빈 대화 기록이 아니라 `undefined`로 답합니다. 빈 세션은 아직 아무 말도 하지 않은 세션이라 판정 대상이고, 읽히지 않는 세션은 증거 통로 자체가 없어서 건너뜁니다. 어느 쪽이든 밸브는 닫히지, 열리지 않습니다.
15
+ - **선행 증거 평가기.** `evaluatePrecedent`는 맥락족 규율(discipline)을 위해 이 어댑터가 소유한 증거 어휘입니다. git 어댑터의 `resolveGitAdapterSettings`와 같은 네임스페이스 태도입니다. `subagent`(스폰 종류의 정확한 일치, 종류는 패턴이 아니라 값입니다)와 `tool`(관측된 도구 이름을 정규식으로 대조)을 판정하고, 자기 어휘 밖의 키에는 `undefined`를 돌려줍니다. 이 증거는 판정할 수 없다고 컴파일러에 알리는 신호이며, 그러면 해당 항목은 짐작으로 판정하지 않고 건너뜁니다. 형태가 어긋난 값은 예외가 아니라 증거 없음으로 다룹니다.
16
+ - **텔레메트리 배선.** `runAdapterPath`가 깔때기 전체를 끝까지 실행합니다. 날 페이로드에서 번역(실패는 차단 1행을 기록하고 `2`로 종료), 주입된 이음새를 통한 판정, 깔때기 완결까지 이어져 호출마다 정확히 1행이 남습니다. 증언(witnessed)도 예외가 아닙니다. 판정 이음새를 주입으로 받는 이유는 이 패키지가 covenant 패키지를 직접 가져오지(import) 않기 때문입니다. 의존은 코어를 통해서만, 단방향으로 흐릅니다.
17
+
18
+ 아키텍처 청사진과 설계 근거는 [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
19
+
20
+ ## 라이선스
21
+
22
+ MIT
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @polydeukes/adapter-claude-code
2
+
3
+ **English** · [한국어](https://github.com/huskyhoochu/polydeukes/blob/main/packages/adapter-claude-code/README.ko.md)
4
+
5
+ > The boundary where Claude Code's vocabulary is translated away. PreToolUse hook payloads become the agent-neutral covenant input IR before anything reaches the core.
6
+
7
+ **Alpha.** Agent and tool literals live *here* by design — this package exists so they never reach the core, which is how the core's agent-neutrality stays a testable claim rather than a slogan.
8
+
9
+ ## What lives here
10
+
11
+ - **Payload up-translation** — a raw PreToolUse payload becomes a `CovenantInput` (tool calls and subagent spawns). A `Task` call carrying a subagent type maps to a spawn; a payload that cannot be classified is a translation *failure*, and failures block (fail-closed) instead of degrading into a guess.
12
+ - **Virtual post-state** — computes what a file *would* contain after an `Edit`/`Write`/`MultiEdit` applies, without touching disk. Content-aware covenants judge the proposed result, not the file as it currently is — including sequential multi-edit application and file-creation conventions.
13
+ - **File-change evidence** — `collectFileChanges` pairs the disk pre-state with the virtual post-state into union evidence (`create` when no pre-state exists, `modify` otherwise) attached to the mutating tool call's own `fileChange` field — the evidence discipline judgments consume. An unresolvable post-state yields nothing (the real tool rejects the same edit), and evidence is never fabricated for non-mutating calls.
14
+ - **Transcript provider** — `transcriptFromJsonl` / `transcriptFromJsonlFile` turn a session JSONL file into a `CanonicalTranscript`, the TTL witness's real data source: only positively-identified human-typed messages are admitted, so an AI can never synthesize its own witness. It answers `findToolCalls` too, extracting calls from `tool_use` blocks — when a call's `input` is not a flat object the block still yields a call with empty `args`, because the existence of the call is itself the evidence. A read failure answers `undefined` rather than an empty transcript — an empty session has said nothing yet and is judged, an unreadable one is no evidence channel at all and is skipped. Either way the valve turns off, never open.
15
+ - **Precedent evidence evaluator** — `evaluatePrecedent` is this adapter's own evidence vocabulary for the context-family discipline, the same namespace stance as `resolveGitAdapterSettings` in the git adapter. It judges `subagent` (exact spawn-kind equality — a kind is a value, not a pattern) and `tool` (observed tool names matched as a regular expression), and returns `undefined` for any key outside its vocabulary — the handshake that tells the compiler this evidence is unjudgeable, so the entry skips instead of judging on a guess. A malformed value is absent evidence, never a throw.
16
+ - **Telemetry wiring** — `runAdapterPath` drives the full funnel: raw payload → translation (a failure logs one blocked record and exits `2`) → dispatch through an *injected* seam → funnel completion, so exactly one record lands per call, witnessed rows included. The dispatch seam is injected because this package never imports the covenant package — dependencies stay one-way, through the core only.
17
+
18
+ See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture blueprint and design rationale.
19
+
20
+ ## License
21
+
22
+ MIT
@@ -0,0 +1,23 @@
1
+ /**
2
+ * File-change evidence computation (COVENANT-10 §4.3) — turns one Edit/Write/MultiEdit/
3
+ * NotebookEdit payload into the agent-neutral `FileChange` evidence the discipline layer
4
+ * judges.
5
+ *
6
+ * Pure translation: pre-state comes through an injected reader (disk is the caller's
7
+ * choice), post-state through `virtualPostState`. An unresolvable post-state OMITS the
8
+ * element (the specified disposition, PRD §4.3 — the real tool rejects the same edit,
9
+ * so there is no change to judge); non-mutating tools and unparseable envelopes yield
10
+ * an empty array, never an error.
11
+ */
12
+ import type { FileChange } from '@polydeukes/core';
13
+ /**
14
+ * Collect the file-change evidence of one raw PreToolUse payload (COVENANT-10 §4.3).
15
+ *
16
+ * `readPreState` returns the target file's current content, `null` when it does not
17
+ * exist — that absence IS the union discriminant (CORE-06 §4.2): no pre-state tags a
18
+ * `create`, an existing one a `modify` (these tools cannot delete). Evidence is
19
+ * singular like its IR home (`toolCall.fileChange`): one payload proves at most one
20
+ * change, and `null` means nothing provable. NotebookEdit names its target differently
21
+ * and proves a cell rather than a file, so it branches into its own computation.
22
+ */
23
+ export declare function collectFileChanges(rawPayload: unknown, readPreState: (filePath: string) => string | null): FileChange | null;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * File-change evidence computation (COVENANT-10 §4.3) — turns one Edit/Write/MultiEdit/
3
+ * NotebookEdit payload into the agent-neutral `FileChange` evidence the discipline layer
4
+ * judges.
5
+ *
6
+ * Pure translation: pre-state comes through an injected reader (disk is the caller's
7
+ * choice), post-state through `virtualPostState`. An unresolvable post-state OMITS the
8
+ * element (the specified disposition, PRD §4.3 — the real tool rejects the same edit,
9
+ * so there is no change to judge); non-mutating tools and unparseable envelopes yield
10
+ * an empty array, never an error.
11
+ */
12
+ import { isPlainObject } from '@polydeukes/core';
13
+ import { parsePayloadEnvelope } from './payload-envelope.js';
14
+ import { virtualPostState } from './virtual-post-state.js';
15
+ /** The tools whose post-state is computable — the only fileChanges contributors. */
16
+ const MUTATING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
17
+ /** The notebook's cells, `null` when the pre-state does not parse as a notebook. */
18
+ function parseNotebookCells(content) {
19
+ let notebook;
20
+ try {
21
+ notebook = JSON.parse(content);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ if (!isPlainObject(notebook) || !Array.isArray(notebook.cells))
27
+ return null;
28
+ return notebook.cells;
29
+ }
30
+ /**
31
+ * The source text of the cell `cellId` names — `null` when no cell carries that id or
32
+ * its source is neither spelling nbformat allows. An array source joins as-is: each
33
+ * line already carries its own newline, so a '\n' join would double every break.
34
+ */
35
+ function cellSource(cells, cellId) {
36
+ if (typeof cellId !== 'string')
37
+ return null;
38
+ const cell = cells.find((entry) => isPlainObject(entry) && entry.id === cellId);
39
+ if (!isPlainObject(cell))
40
+ return null;
41
+ const { source } = cell;
42
+ if (typeof source === 'string')
43
+ return source;
44
+ return Array.isArray(source) ? source.join('') : null;
45
+ }
46
+ /**
47
+ * Cell-level evidence for one NotebookEdit payload (COVENANT-10b §2-d).
48
+ *
49
+ * The judged quantity is the target CELL's source, not the notebook's serialization:
50
+ * `path` names the notebook while `pre`/`post` carry the cell's text, so a banned word
51
+ * in a cell is judged without JSON-escape noise. `edit_mode` governs the pair — absent
52
+ * means `replace`, an `insert` adds everything it writes (empty `pre`), and a cell
53
+ * `delete` removes all of it (empty `post`, whatever `new_source` carries).
54
+ *
55
+ * Every form the real tool would reject or this adapter cannot read — a missing or
56
+ * unparseable notebook, an unnamed cell, an unrecognized mode, an `insert` without the
57
+ * `cell_type` it requires, a cell source that is not text — OMITS the evidence instead
58
+ * of guessing one, and the mention fallback owns the call.
59
+ */
60
+ function collectNotebookChange(toolInput, readPreState) {
61
+ const notebookPath = toolInput.notebook_path;
62
+ if (typeof notebookPath !== 'string')
63
+ return null;
64
+ const newSource = toolInput.new_source;
65
+ if (typeof newSource !== 'string')
66
+ return null;
67
+ const preState = readPreState(notebookPath);
68
+ if (preState === null)
69
+ return null;
70
+ const cells = parseNotebookCells(preState);
71
+ if (cells === null)
72
+ return null;
73
+ const editMode = toolInput.edit_mode ?? 'replace';
74
+ if (editMode === 'insert') {
75
+ if (typeof toolInput.cell_type !== 'string')
76
+ return null;
77
+ return { kind: 'modify', path: notebookPath, pre: '', post: newSource };
78
+ }
79
+ if (editMode !== 'replace' && editMode !== 'delete')
80
+ return null;
81
+ const pre = cellSource(cells, toolInput.cell_id);
82
+ if (pre === null)
83
+ return null;
84
+ return {
85
+ kind: 'modify',
86
+ path: notebookPath,
87
+ pre,
88
+ post: editMode === 'delete' ? '' : newSource,
89
+ };
90
+ }
91
+ /**
92
+ * Collect the file-change evidence of one raw PreToolUse payload (COVENANT-10 §4.3).
93
+ *
94
+ * `readPreState` returns the target file's current content, `null` when it does not
95
+ * exist — that absence IS the union discriminant (CORE-06 §4.2): no pre-state tags a
96
+ * `create`, an existing one a `modify` (these tools cannot delete). Evidence is
97
+ * singular like its IR home (`toolCall.fileChange`): one payload proves at most one
98
+ * change, and `null` means nothing provable. NotebookEdit names its target differently
99
+ * and proves a cell rather than a file, so it branches into its own computation.
100
+ */
101
+ export function collectFileChanges(rawPayload, readPreState) {
102
+ const envelope = parsePayloadEnvelope(rawPayload);
103
+ if (envelope.ok !== true)
104
+ return null;
105
+ if (!MUTATING_TOOLS.has(envelope.toolName))
106
+ return null;
107
+ if (envelope.toolName === 'NotebookEdit') {
108
+ return collectNotebookChange(envelope.toolInput, readPreState);
109
+ }
110
+ const filePath = envelope.toolInput.file_path;
111
+ if (typeof filePath !== 'string')
112
+ return null;
113
+ const pre = readPreState(filePath);
114
+ const post = virtualPostState(rawPayload, pre);
115
+ if (post.ok !== true)
116
+ return null;
117
+ return pre === null
118
+ ? { kind: 'create', path: filePath, post: post.value.content }
119
+ : { kind: 'modify', path: filePath, pre, post: post.value.content };
120
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @polydeukes/adapter-claude-code — up-translates Claude Code PreToolUse hook
3
+ * payloads into the agent-neutral covenant input IR (ADAPTER-01).
4
+ *
5
+ * Pre-alpha. Pure translation only — no I/O, no process spawning. Agent and tool
6
+ * literals live here by design: this package is the boundary where Claude Code's
7
+ * vocabulary is translated away before it reaches the core.
8
+ */
9
+ import { type CovenantInput } from '@polydeukes/core';
10
+ export { collectFileChanges } from './file-changes.js';
11
+ export { evaluatePrecedent } from './precedent.js';
12
+ export { type DispatchOutcome, runAdapterPath } from './run-adapter-path.js';
13
+ export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, transcriptPathFromPayload, } from './session-vocabulary.js';
14
+ export { transcriptFromJsonl, transcriptFromJsonlFile } from './transcript.js';
15
+ export { type VirtualPostState, virtualPostState } from './virtual-post-state.js';
16
+ /**
17
+ * `ClaudePreToolUsePayload` — a Claude Code PreToolUse hook payload (PRD §4.1).
18
+ *
19
+ * Only `tool_name` and `tool_input` are required; the rest is preserved when present.
20
+ * The agent/tool literals this package interprets live in the *values* of these fields,
21
+ * never in core — this boundary is the package's reason to exist.
22
+ */
23
+ export type ClaudePreToolUsePayload = {
24
+ hook_event_name?: string;
25
+ session_id?: string;
26
+ transcript_path?: string;
27
+ cwd?: string;
28
+ tool_name: string;
29
+ tool_input: Record<string, unknown>;
30
+ };
31
+ /**
32
+ * `TranslatedEvent` — the result of up-translating one payload (PRD §4.2).
33
+ *
34
+ * Success carries the IR fragment; failure carries a human-readable `reason`. A `Task`
35
+ * with a string `subagent_type` becomes a `subagentSpawn`; every other tool becomes a
36
+ * `toolCall`. Classification failure never demotes to a `toolCall` — losing spawn
37
+ * evidence is a bypass vector (PRD §4.1).
38
+ */
39
+ export type TranslatedEvent = {
40
+ ok: true;
41
+ kind: 'toolCall';
42
+ value: {
43
+ name: string;
44
+ args?: Record<string, unknown>;
45
+ };
46
+ } | {
47
+ ok: true;
48
+ kind: 'subagentSpawn';
49
+ value: {
50
+ kind: string;
51
+ };
52
+ } | {
53
+ ok: false;
54
+ reason: string;
55
+ };
56
+ /**
57
+ * Up-translate one Claude Code payload into an IR fragment (pure, PRD §4.2).
58
+ *
59
+ * Never throws: a payload that cannot be classified — a non-object, a missing/invalid
60
+ * `tool_name` or `tool_input`, or a `Task` lacking a string `subagent_type` — resolves
61
+ * to `{ ok: false, reason }` (fail-closed, PRD §5.2).
62
+ */
63
+ export declare function translateEvent(payload: unknown): TranslatedEvent;
64
+ /**
65
+ * Fold a sequence of payloads into one {@link CovenantInput} (pure, PRD §4.2).
66
+ *
67
+ * Preserves observation order into `toolCalls` / `subagentSpawns`; `userMessages` is
68
+ * always `[]` (ADAPTER-04 supplies witness evidence later). If any payload fails
69
+ * classification the whole build fails closed with the blocking exit code — a silent
70
+ * drop would be a bypass vector (PRD §5.2/§7).
71
+ */
72
+ export declare function buildCovenantInput(payloads: unknown[]): {
73
+ ok: true;
74
+ value: CovenantInput;
75
+ } | {
76
+ ok: false;
77
+ exitCode: 2;
78
+ reason: string;
79
+ };
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @polydeukes/adapter-claude-code — up-translates Claude Code PreToolUse hook
3
+ * payloads into the agent-neutral covenant input IR (ADAPTER-01).
4
+ *
5
+ * Pre-alpha. Pure translation only — no I/O, no process spawning. Agent and tool
6
+ * literals live here by design: this package is the boundary where Claude Code's
7
+ * vocabulary is translated away before it reaches the core.
8
+ */
9
+ import { EXIT_BREAK_BLOCKING } from '@polydeukes/core';
10
+ import { parsePayloadEnvelope } from './payload-envelope.js';
11
+ export { collectFileChanges } from './file-changes.js';
12
+ export { evaluatePrecedent } from './precedent.js';
13
+ export { runAdapterPath } from './run-adapter-path.js';
14
+ export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, transcriptPathFromPayload, } from './session-vocabulary.js';
15
+ export { transcriptFromJsonl, transcriptFromJsonlFile } from './transcript.js';
16
+ export { virtualPostState } from './virtual-post-state.js';
17
+ /**
18
+ * Up-translate one Claude Code payload into an IR fragment (pure, PRD §4.2).
19
+ *
20
+ * Never throws: a payload that cannot be classified — a non-object, a missing/invalid
21
+ * `tool_name` or `tool_input`, or a `Task` lacking a string `subagent_type` — resolves
22
+ * to `{ ok: false, reason }` (fail-closed, PRD §5.2).
23
+ */
24
+ export function translateEvent(payload) {
25
+ const envelope = parsePayloadEnvelope(payload);
26
+ if (envelope.ok !== true) {
27
+ return { ok: false, reason: envelope.reason };
28
+ }
29
+ const { toolName, toolInput } = envelope;
30
+ if (toolName === 'Task') {
31
+ if (typeof toolInput.subagent_type !== 'string') {
32
+ return { ok: false, reason: 'Task payload is missing a string subagent_type' };
33
+ }
34
+ return { ok: true, kind: 'subagentSpawn', value: { kind: toolInput.subagent_type } };
35
+ }
36
+ return { ok: true, kind: 'toolCall', value: { name: toolName, args: toolInput } };
37
+ }
38
+ /**
39
+ * Fold a sequence of payloads into one {@link CovenantInput} (pure, PRD §4.2).
40
+ *
41
+ * Preserves observation order into `toolCalls` / `subagentSpawns`; `userMessages` is
42
+ * always `[]` (ADAPTER-04 supplies witness evidence later). If any payload fails
43
+ * classification the whole build fails closed with the blocking exit code — a silent
44
+ * drop would be a bypass vector (PRD §5.2/§7).
45
+ */
46
+ export function buildCovenantInput(payloads) {
47
+ const input = { toolCalls: [], subagentSpawns: [], userMessages: [] };
48
+ for (let index = 0; index < payloads.length; index++) {
49
+ const translated = translateEvent(payloads[index]);
50
+ if (translated.ok !== true) {
51
+ return {
52
+ ok: false,
53
+ exitCode: EXIT_BREAK_BLOCKING,
54
+ reason: `payload at index ${index} failed classification: ${translated.reason}`,
55
+ };
56
+ }
57
+ if (translated.kind === 'toolCall') {
58
+ input.toolCalls.push(translated.value);
59
+ }
60
+ else {
61
+ input.subagentSpawns.push(translated.value);
62
+ }
63
+ }
64
+ return { ok: true, value: input };
65
+ }
@@ -0,0 +1,21 @@
1
+ /** Internal shared envelope validation — not part of the package's public surface. */
2
+ /**
3
+ * `PayloadEnvelope` — the validated outer shape every PreToolUse payload shares.
4
+ *
5
+ * Both `translateEvent` and `virtualPostState` accept `unknown` and must agree on
6
+ * what counts as a well-formed envelope; sharing the validator keeps their
7
+ * fail-closed diagnostics from drifting apart.
8
+ */
9
+ export type PayloadEnvelope = {
10
+ ok: true;
11
+ toolName: string;
12
+ toolInput: Record<string, unknown>;
13
+ } | {
14
+ ok: false;
15
+ reason: string;
16
+ };
17
+ /**
18
+ * Validate the payload envelope (plain object, string `tool_name`, plain-object
19
+ * `tool_input`). Never throws — failure resolves to `{ ok: false, reason }`.
20
+ */
21
+ export declare function parsePayloadEnvelope(payload: unknown): PayloadEnvelope;
@@ -0,0 +1,18 @@
1
+ /** Internal shared envelope validation — not part of the package's public surface. */
2
+ import { isPlainObject } from '@polydeukes/core';
3
+ /**
4
+ * Validate the payload envelope (plain object, string `tool_name`, plain-object
5
+ * `tool_input`). Never throws — failure resolves to `{ ok: false, reason }`.
6
+ */
7
+ export function parsePayloadEnvelope(payload) {
8
+ if (!isPlainObject(payload)) {
9
+ return { ok: false, reason: 'payload is not a non-null object' };
10
+ }
11
+ if (typeof payload.tool_name !== 'string') {
12
+ return { ok: false, reason: 'payload is missing a string tool_name' };
13
+ }
14
+ if (!isPlainObject(payload.tool_input)) {
15
+ return { ok: false, reason: 'payload is missing a non-null object tool_input' };
16
+ }
17
+ return { ok: true, toolName: payload.tool_name, toolInput: payload.tool_input };
18
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Adapter-owned precedent evidence evaluator (COVENANT-13 §4.4).
3
+ *
4
+ * The core owns the `command` evidence vocabulary and validates only the container
5
+ * shape of everything else; ecosystem values — spawn kinds and tool names — are this
6
+ * adapter's vocabulary, so this package validates and judges them (the
7
+ * `resolveGitAdapterSettings` namespace precedent). The covenant assembly injects this
8
+ * evaluator; `undefined` is the handshake that makes assembly fail closed on a key no
9
+ * adapter recognizes.
10
+ */
11
+ import type { CanonicalTranscript } from '@polydeukes/core';
12
+ /**
13
+ * Judge one `requirePrecedent` evidence object against the session (PRD §4.4).
14
+ *
15
+ * - `subagent`: exact spawn-kind equality (a kind is a value, not a pattern).
16
+ * - `tool`: the observed tool names matched as a regular expression.
17
+ * - anything else: `undefined` — outside this adapter's vocabulary, including the core's
18
+ * own `command` key, which the covenant compiler evaluates itself.
19
+ *
20
+ * Both vocabularies require the call to have RUN and reported success (COVENANT-13b
21
+ * §4.4): a call the covenant blocked, one the human refused, and one that simply failed
22
+ * carry the same outcome, and none of them did the work the discipline demands. The spawn
23
+ * axis therefore reads the joined tool calls — the spawn query carries no outcome —
24
+ * identifying a spawn by the same field the transcript provider does.
25
+ *
26
+ * A malformed value of a known key (non-string, non-compiling pattern) is also
27
+ * `undefined`: the adapter cannot evaluate it, and assembly must fail loud. Answering
28
+ * `false` instead would make the gate permanently unsatisfiable — no amount of actually
29
+ * doing the required action could ever open it, with nothing diagnosing why.
30
+ */
31
+ export declare function evaluatePrecedent(evidence: Record<string, unknown>, transcript: CanonicalTranscript): boolean | undefined;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Adapter-owned precedent evidence evaluator (COVENANT-13 §4.4).
3
+ *
4
+ * The core owns the `command` evidence vocabulary and validates only the container
5
+ * shape of everything else; ecosystem values — spawn kinds and tool names — are this
6
+ * adapter's vocabulary, so this package validates and judges them (the
7
+ * `resolveGitAdapterSettings` namespace precedent). The covenant assembly injects this
8
+ * evaluator; `undefined` is the handshake that makes assembly fail closed on a key no
9
+ * adapter recognizes.
10
+ */
11
+ /**
12
+ * Judge one `requirePrecedent` evidence object against the session (PRD §4.4).
13
+ *
14
+ * - `subagent`: exact spawn-kind equality (a kind is a value, not a pattern).
15
+ * - `tool`: the observed tool names matched as a regular expression.
16
+ * - anything else: `undefined` — outside this adapter's vocabulary, including the core's
17
+ * own `command` key, which the covenant compiler evaluates itself.
18
+ *
19
+ * Both vocabularies require the call to have RUN and reported success (COVENANT-13b
20
+ * §4.4): a call the covenant blocked, one the human refused, and one that simply failed
21
+ * carry the same outcome, and none of them did the work the discipline demands. The spawn
22
+ * axis therefore reads the joined tool calls — the spawn query carries no outcome —
23
+ * identifying a spawn by the same field the transcript provider does.
24
+ *
25
+ * A malformed value of a known key (non-string, non-compiling pattern) is also
26
+ * `undefined`: the adapter cannot evaluate it, and assembly must fail loud. Answering
27
+ * `false` instead would make the gate permanently unsatisfiable — no amount of actually
28
+ * doing the required action could ever open it, with nothing diagnosing why.
29
+ */
30
+ export function evaluatePrecedent(evidence, transcript) {
31
+ if ('subagent' in evidence) {
32
+ const kind = evidence.subagent;
33
+ if (typeof kind !== 'string') {
34
+ return undefined;
35
+ }
36
+ return transcript
37
+ .findToolCalls()
38
+ .some((call) => call.succeeded === true && call.args.subagent_type === kind);
39
+ }
40
+ if ('tool' in evidence) {
41
+ const pattern = evidence.tool;
42
+ if (typeof pattern !== 'string') {
43
+ return undefined;
44
+ }
45
+ let matcher;
46
+ try {
47
+ matcher = new RegExp(pattern);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ return transcript
53
+ .findToolCalls()
54
+ .some((call) => call.succeeded === true && matcher.test(call.name));
55
+ }
56
+ return undefined;
57
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `runAdapterPath` — the adapter path's single wiring entry point (ADAPTER-03).
3
+ *
4
+ * Composes translation → injected dispatch → funnel-supplement recording so every
5
+ * adapter-path call leaves exactly one telemetry row when summed with downstream
6
+ * records (PRD §4.3 table is canonical). I/O lives here and only here — the
7
+ * translate layer (index.ts) stays pure.
8
+ */
9
+ /**
10
+ * `DispatchOutcome` — structural mirror of the dispatcher's return (PRD §4.2).
11
+ *
12
+ * Deliberately declared here instead of imported: dependencies are one-way (adapter →
13
+ * core only), so the covenant package is never imported. Contract drift is caught by
14
+ * the assembler's typecheck when the real dispatcher is bound to the seam.
15
+ */
16
+ export type DispatchOutcome = {
17
+ exitCode: 0 | 2;
18
+ results: {
19
+ label: string;
20
+ exitCode: 0 | 2;
21
+ }[];
22
+ };
23
+ /**
24
+ * Run one PreToolUse payload through the adapter path (PRD §4.1).
25
+ *
26
+ * Fail-closed on the verdict axis: an unparseable payload, a classification failure,
27
+ * or a rejecting dispatch all resolve to `{ exitCode: 2 }` with one adapter `blocked`
28
+ * record — never a thrown error (an unhandled rejection would exit the hook
29
+ * non-blocking, a bypass vector). The funnel supplement is the exact rule
30
+ * `exitCode 0 && results.length 0 → one adapter passed record`; every other outcome
31
+ * appends nothing because downstream already recorded (PRD §4.3, no double counting).
32
+ */
33
+ export declare function runAdapterPath(spec: {
34
+ /** Raw hook stdin — one PreToolUse payload as a JSON string. */
35
+ rawPayload: string;
36
+ telemetryPath: string;
37
+ /** Injected dispatch seam — the assembler binds the real dispatcher here. */
38
+ dispatch: (stdinPayload: string) => Promise<DispatchOutcome>;
39
+ /** Label for adapter-level records. Default: 'adapter-claude-code'. */
40
+ adapterLabel?: string;
41
+ }): Promise<{
42
+ exitCode: 0 | 2;
43
+ }>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `runAdapterPath` — the adapter path's single wiring entry point (ADAPTER-03).
3
+ *
4
+ * Composes translation → injected dispatch → funnel-supplement recording so every
5
+ * adapter-path call leaves exactly one telemetry row when summed with downstream
6
+ * records (PRD §4.3 table is canonical). I/O lives here and only here — the
7
+ * translate layer (index.ts) stays pure.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { appendRecordFailOpen, EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from '@polydeukes/core';
11
+ import { collectFileChanges } from './file-changes.js';
12
+ import { buildCovenantInput } from './index.js';
13
+ /** Default label for adapter-level telemetry records. */
14
+ const DEFAULT_ADAPTER_LABEL = 'adapter-claude-code';
15
+ /**
16
+ * Real-fs pre-state reader for fileChanges — `null` only for true absence (ENOENT).
17
+ *
18
+ * Any other read failure (permissions, a directory target, fd exhaustion) throws:
19
+ * `null` is the IR's creation sentinel, and a poisoned `pre: null` on an existing
20
+ * file would let a path-family discipline uphold the overwrite (fail-open). The
21
+ * caller converts the throw into one adapter `blocked` record (PR #23 review).
22
+ */
23
+ function readPreStateFromDisk(filePath) {
24
+ try {
25
+ return readFileSync(filePath, 'utf-8');
26
+ }
27
+ catch (error) {
28
+ if (error.code === 'ENOENT')
29
+ return null;
30
+ throw error;
31
+ }
32
+ }
33
+ /**
34
+ * Run one PreToolUse payload through the adapter path (PRD §4.1).
35
+ *
36
+ * Fail-closed on the verdict axis: an unparseable payload, a classification failure,
37
+ * or a rejecting dispatch all resolve to `{ exitCode: 2 }` with one adapter `blocked`
38
+ * record — never a thrown error (an unhandled rejection would exit the hook
39
+ * non-blocking, a bypass vector). The funnel supplement is the exact rule
40
+ * `exitCode 0 && results.length 0 → one adapter passed record`; every other outcome
41
+ * appends nothing because downstream already recorded (PRD §4.3, no double counting).
42
+ */
43
+ export async function runAdapterPath(spec) {
44
+ const label = spec.adapterLabel ?? DEFAULT_ADAPTER_LABEL;
45
+ const blockAndRecord = () => {
46
+ appendRecordFailOpen(spec.telemetryPath, { event: 'blocked', label, subject: '-' });
47
+ return { exitCode: EXIT_BREAK_BLOCKING };
48
+ };
49
+ let payload;
50
+ try {
51
+ payload = JSON.parse(spec.rawPayload);
52
+ }
53
+ catch {
54
+ return blockAndRecord();
55
+ }
56
+ const built = buildCovenantInput([payload]);
57
+ if (built.ok !== true) {
58
+ return blockAndRecord();
59
+ }
60
+ // Attach pre/post evidence to the call it belongs to (CORE-06 §4.2) — this path
61
+ // translates exactly one payload, so the one evidence rides toolCalls[0]. Attached
62
+ // only when provable: a non-mutating payload leaves its call unproven. A pre-state
63
+ // read failure that is not absence blocks: evidence that cannot be gathered must not
64
+ // dispatch a shape that reads as creation.
65
+ let evidence;
66
+ try {
67
+ evidence = collectFileChanges(payload, readPreStateFromDisk);
68
+ }
69
+ catch {
70
+ return blockAndRecord();
71
+ }
72
+ const input = evidence === null
73
+ ? built.value
74
+ : {
75
+ ...built.value,
76
+ toolCalls: built.value.toolCalls.map((call, index) => index === 0 ? { ...call, fileChange: evidence } : call),
77
+ };
78
+ let outcome;
79
+ try {
80
+ outcome = await spec.dispatch(JSON.stringify(input));
81
+ }
82
+ catch {
83
+ return blockAndRecord();
84
+ }
85
+ if (outcome.exitCode === EXIT_UPHOLD && outcome.results.length === 0) {
86
+ appendRecordFailOpen(spec.telemetryPath, { event: 'passed', label, subject: '-' });
87
+ }
88
+ return { exitCode: outcome.exitCode };
89
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Session-surface tool vocabulary (DIST-01 §3-b).
3
+ *
4
+ * The PreToolUse surface's tool names, owned by the adapter that speaks them — the
5
+ * `STAGED_WRITE`/`STAGED_DELETE` precedent on the git side. Assembly consumes these
6
+ * values; the core never sees them, and no other project has to copy a hook file to
7
+ * get them. The transcript reader lives here for the same reason: `transcript_path`
8
+ * is a Claude Code envelope key, and reading it is translation, not assembly.
9
+ */
10
+ /** Tool names whose calls mutate a file and carry `fileChange` evidence. */
11
+ export declare const MUTATING_TOOLS: string[];
12
+ /** Tool names that carry a shell command line instead of a file target. */
13
+ export declare const SHELL_TOOLS: string[];
14
+ /** `tool_input` keys a shell tool's command line travels in. */
15
+ export declare const COMMAND_ARGS: string[];
16
+ /**
17
+ * Read the live transcript path out of a raw PreToolUse payload (DIST-01 §3-b).
18
+ *
19
+ * The transcript path travels in the raw payload only — up-translation drops it, so it
20
+ * is read from the string and nowhere else. Every failure narrows to `undefined`
21
+ * (unparseable JSON, a non-object payload, a non-string field), never a throw: lost
22
+ * evidence leaves the dispatcher on its `noopTranscript` default, which shuts the
23
+ * witness valve rather than opening it (ADAPTER-04 §4.4). A payload this function
24
+ * cannot parse is still dispatched — `runAdapterPath` owns that verdict.
25
+ */
26
+ export declare function transcriptPathFromPayload(rawPayload: string): string | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session-surface tool vocabulary (DIST-01 §3-b).
3
+ *
4
+ * The PreToolUse surface's tool names, owned by the adapter that speaks them — the
5
+ * `STAGED_WRITE`/`STAGED_DELETE` precedent on the git side. Assembly consumes these
6
+ * values; the core never sees them, and no other project has to copy a hook file to
7
+ * get them. The transcript reader lives here for the same reason: `transcript_path`
8
+ * is a Claude Code envelope key, and reading it is translation, not assembly.
9
+ */
10
+ import { isPlainObject } from '@polydeukes/core';
11
+ /** Tool names whose calls mutate a file and carry `fileChange` evidence. */
12
+ export const MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
13
+ /** Tool names that carry a shell command line instead of a file target. */
14
+ export const SHELL_TOOLS = ['Bash'];
15
+ /** `tool_input` keys a shell tool's command line travels in. */
16
+ export const COMMAND_ARGS = ['command'];
17
+ /**
18
+ * Read the live transcript path out of a raw PreToolUse payload (DIST-01 §3-b).
19
+ *
20
+ * The transcript path travels in the raw payload only — up-translation drops it, so it
21
+ * is read from the string and nowhere else. Every failure narrows to `undefined`
22
+ * (unparseable JSON, a non-object payload, a non-string field), never a throw: lost
23
+ * evidence leaves the dispatcher on its `noopTranscript` default, which shuts the
24
+ * witness valve rather than opening it (ADAPTER-04 §4.4). A payload this function
25
+ * cannot parse is still dispatched — `runAdapterPath` owns that verdict.
26
+ */
27
+ export function transcriptPathFromPayload(rawPayload) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(rawPayload);
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ if (!isPlainObject(parsed) || typeof parsed.transcript_path !== 'string') {
36
+ return undefined;
37
+ }
38
+ return parsed.transcript_path;
39
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * JSONL-backed `CanonicalTranscript` provider (ADAPTER-04) — parses a Claude Code
3
+ * session transcript into the agent-neutral query seam the witness valve judges over.
4
+ *
5
+ * JSONL vocabulary (`origin`, `subagent_type`, ISO timestamps) stays confined to this
6
+ * package, never the core. Parsing happens once (a snapshot); the queries are pure
7
+ * reads. Every failure — unreadable file, broken line, shape mismatch — reduces
8
+ * evidence instead of throwing (fail-closed, PRD §4.4).
9
+ */
10
+ import { type CanonicalTranscript } from '@polydeukes/core';
11
+ /**
12
+ * Parse JSONL transcript text into a {@link CanonicalTranscript} (PRD §4.2–4.4).
13
+ *
14
+ * One pass over the lines builds an immutable snapshot; the queries only read it.
15
+ * Unparseable lines, non-object lines, and shape-mismatched entries are skipped
16
+ * silently — a broken line never aborts the rest of the scan and never throws.
17
+ */
18
+ export declare function transcriptFromJsonl(text: string): CanonicalTranscript;
19
+ /**
20
+ * Read a transcript file and parse it (PRD §5.4).
21
+ *
22
+ * ANY read failure — missing file, permission, directory — answers `undefined`, never a
23
+ * throw. It is deliberately NOT an empty transcript: the two are different facts, and
24
+ * collapsing them hid the more likely one. An empty transcript is a session that has said
25
+ * nothing yet, and judging against it is correct. An unreadable one is no evidence channel
26
+ * at all, so the context family must skip rather than demand evidence from a session
27
+ * nobody can read — while the witness valve reads the same absence and stays shut, leaving
28
+ * a dead end with no message naming the cause (COVENANT-13 §4.5).
29
+ *
30
+ * Either way the valve turns off, never open: `undefined` leaves the dispatcher on its
31
+ * `noopTranscript` default.
32
+ */
33
+ export declare function transcriptFromJsonlFile(path: string): CanonicalTranscript | undefined;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * JSONL-backed `CanonicalTranscript` provider (ADAPTER-04) — parses a Claude Code
3
+ * session transcript into the agent-neutral query seam the witness valve judges over.
4
+ *
5
+ * JSONL vocabulary (`origin`, `subagent_type`, ISO timestamps) stays confined to this
6
+ * package, never the core. Parsing happens once (a snapshot); the queries are pure
7
+ * reads. Every failure — unreadable file, broken line, shape mismatch — reduces
8
+ * evidence instead of throwing (fail-closed, PRD §4.4).
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { isPlainObject, } from '@polydeukes/core';
12
+ /**
13
+ * Extract a human utterance from one entry, or `undefined` (PRD §4.2).
14
+ *
15
+ * The allowlist is positive identification: `type === 'user'`, `origin.kind === 'human'`,
16
+ * and a plain-string `message.content`. Anything else — tool_result blocks,
17
+ * task-notifications, origin-less command wrappers — is an AI-controlled or ambiguous
18
+ * surface and never counts as human. A missing/unparseable timestamp keeps the message
19
+ * with `timestampMs` undefined; fabricating freshness would open the witness.
20
+ */
21
+ function toUserMessage(entry) {
22
+ if (entry.type !== 'user') {
23
+ return undefined;
24
+ }
25
+ if (!isPlainObject(entry.origin) || entry.origin.kind !== 'human') {
26
+ return undefined;
27
+ }
28
+ if (!isPlainObject(entry.message) || typeof entry.message.content !== 'string') {
29
+ return undefined;
30
+ }
31
+ const parsed = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN;
32
+ return {
33
+ text: entry.message.content,
34
+ timestampMs: Number.isFinite(parsed) ? parsed : undefined,
35
+ };
36
+ }
37
+ /**
38
+ * Extract subagent invocations from one entry (PRD §4.3).
39
+ *
40
+ * Detection keys on the *field*, not the tool name (the real tool has been renamed
41
+ * Task → Agent): any `tool_use` block whose `input.subagent_type` is a string is an
42
+ * invocation. A block that cannot prove its kind is dropped (evidence reduction).
43
+ */
44
+ function toSubagentInvocations(entry) {
45
+ if (entry.type !== 'assistant' || !isPlainObject(entry.message)) {
46
+ return [];
47
+ }
48
+ const content = entry.message.content;
49
+ if (!Array.isArray(content)) {
50
+ return [];
51
+ }
52
+ const invocations = [];
53
+ for (const block of content) {
54
+ if (isPlainObject(block) &&
55
+ block.type === 'tool_use' &&
56
+ isPlainObject(block.input) &&
57
+ typeof block.input.subagent_type === 'string') {
58
+ invocations.push({ kind: block.input.subagent_type });
59
+ }
60
+ }
61
+ return invocations;
62
+ }
63
+ /**
64
+ * Extract tool calls from one entry (COVENANT-13 §4.3).
65
+ *
66
+ * Positive identification on the *name*: any `tool_use` block with a string `name` is a
67
+ * call, in observation order. A non-plain `input` empties the args but keeps the block —
68
+ * the call's existence is itself the evidence, and dropping it would shrink a precedent
69
+ * gate's evidence beyond what the malformed field justifies. Spawn blocks surface here
70
+ * too: the same fact answers two queries. A non-string `id` is left absent, which the
71
+ * join then reads as an unproven outcome.
72
+ */
73
+ function toToolCalls(entry) {
74
+ if (entry.type !== 'assistant' || !isPlainObject(entry.message)) {
75
+ return [];
76
+ }
77
+ const content = entry.message.content;
78
+ if (!Array.isArray(content)) {
79
+ return [];
80
+ }
81
+ const calls = [];
82
+ for (const block of content) {
83
+ if (isPlainObject(block) && block.type === 'tool_use' && typeof block.name === 'string') {
84
+ calls.push({
85
+ name: block.name,
86
+ args: isPlainObject(block.input) ? { ...block.input } : {},
87
+ ...(typeof block.id === 'string' ? { id: block.id } : {}),
88
+ });
89
+ }
90
+ }
91
+ return calls;
92
+ }
93
+ /**
94
+ * Extract the outcomes reported by one entry's result blocks (COVENANT-13b §4.2).
95
+ *
96
+ * Results ride `user` entries and reference the call they answer; only that reference and
97
+ * the error marker are read, never the result body. Success is ENUMERATED — no marker, or
98
+ * a marker of exactly `false` — so every other value, boolean or not, reads as a failure
99
+ * and a shape mismatch can only ever reduce evidence (§7). A block that cannot prove a
100
+ * string reference is dropped alone.
101
+ */
102
+ function toToolResults(entry) {
103
+ if (entry.type !== 'user' || !isPlainObject(entry.message)) {
104
+ return [];
105
+ }
106
+ const content = entry.message.content;
107
+ if (!Array.isArray(content)) {
108
+ return [];
109
+ }
110
+ const results = [];
111
+ for (const block of content) {
112
+ if (isPlainObject(block) &&
113
+ block.type === 'tool_result' &&
114
+ typeof block.tool_use_id === 'string') {
115
+ results.push({
116
+ id: block.tool_use_id,
117
+ succeeded: block.is_error === undefined || block.is_error === false,
118
+ });
119
+ }
120
+ }
121
+ return results;
122
+ }
123
+ /**
124
+ * Parse JSONL transcript text into a {@link CanonicalTranscript} (PRD §4.2–4.4).
125
+ *
126
+ * One pass over the lines builds an immutable snapshot; the queries only read it.
127
+ * Unparseable lines, non-object lines, and shape-mismatched entries are skipped
128
+ * silently — a broken line never aborts the rest of the scan and never throws.
129
+ */
130
+ export function transcriptFromJsonl(text) {
131
+ const userMessages = [];
132
+ const subagentInvocations = [];
133
+ const observedCalls = [];
134
+ const outcomes = new Map();
135
+ for (const line of text.split('\n')) {
136
+ let entry;
137
+ try {
138
+ entry = JSON.parse(line);
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (!isPlainObject(entry)) {
144
+ continue;
145
+ }
146
+ const message = toUserMessage(entry);
147
+ if (message !== undefined) {
148
+ userMessages.push(message);
149
+ }
150
+ subagentInvocations.push(...toSubagentInvocations(entry));
151
+ observedCalls.push(...toToolCalls(entry));
152
+ for (const result of toToolResults(entry)) {
153
+ // First result wins. Real transcripts carry no duplicate reference within one file
154
+ // (the duplicates they do carry are cross-file copies made on resume), and the
155
+ // judge reads one file.
156
+ if (!outcomes.has(result.id))
157
+ outcomes.set(result.id, result.succeeded);
158
+ }
159
+ }
160
+ // The join (COVENANT-13b §4.2). No result found is not ignorance: this provider CAN
161
+ // read the result channel, so silence is success it failed to prove — `undefined` stays
162
+ // reserved for a provider that cannot see results at all.
163
+ const toolCalls = observedCalls.map((call) => ({
164
+ name: call.name,
165
+ args: call.args,
166
+ succeeded: call.id !== undefined && outcomes.get(call.id) === true,
167
+ }));
168
+ // Every query returns fresh objects — never live aliases into the snapshot, down to a
169
+ // call's nested args — so a consumer mutating a result cannot corrupt what later
170
+ // queries read (the same alias-safety contract the core transcriptFromInput upholds).
171
+ return {
172
+ findSubagentInvocations: (kind) => subagentInvocations
173
+ .filter((invocation) => kind === undefined || invocation.kind === kind)
174
+ .map((invocation) => ({ ...invocation })),
175
+ findUserMessages: () => userMessages.map((message) => ({ ...message })),
176
+ findToolCalls: (name) => toolCalls
177
+ .filter((call) => name === undefined || call.name === name)
178
+ .map((call) => ({ name: call.name, args: { ...call.args }, succeeded: call.succeeded })),
179
+ };
180
+ }
181
+ /**
182
+ * Read a transcript file and parse it (PRD §5.4).
183
+ *
184
+ * ANY read failure — missing file, permission, directory — answers `undefined`, never a
185
+ * throw. It is deliberately NOT an empty transcript: the two are different facts, and
186
+ * collapsing them hid the more likely one. An empty transcript is a session that has said
187
+ * nothing yet, and judging against it is correct. An unreadable one is no evidence channel
188
+ * at all, so the context family must skip rather than demand evidence from a session
189
+ * nobody can read — while the witness valve reads the same absence and stays shut, leaving
190
+ * a dead end with no message naming the cause (COVENANT-13 §4.5).
191
+ *
192
+ * Either way the valve turns off, never open: `undefined` leaves the dispatcher on its
193
+ * `noopTranscript` default.
194
+ */
195
+ export function transcriptFromJsonlFile(path) {
196
+ let text;
197
+ try {
198
+ text = readFileSync(path, 'utf-8');
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ return transcriptFromJsonl(text);
204
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Virtual post-state parser (ADAPTER-02) — computes the file content *after* an
3
+ * Edit/Write/MultiEdit payload would be applied, from `tool_input` alone.
4
+ *
5
+ * Pure computation only — no I/O, no process spawning. Reading the pre-state from
6
+ * disk is the caller's job. Claude vocabulary (`old_string`/`new_string`/
7
+ * `replace_all`) stays confined to this package, never the core.
8
+ */
9
+ /**
10
+ * `VirtualPostState` — the result of computing one payload's post-state (PRD §3.1).
11
+ *
12
+ * Success carries the virtual file `{ filePath, content }`; failure carries a
13
+ * human-readable `reason`. A failure is never silently replaced by the pre-state —
14
+ * that would disguise the change as "no change" (a bypass vector, PRD §6).
15
+ */
16
+ export type VirtualPostState = {
17
+ ok: true;
18
+ value: {
19
+ filePath: string;
20
+ content: string;
21
+ };
22
+ } | {
23
+ ok: false;
24
+ reason: string;
25
+ };
26
+ /**
27
+ * Compute the virtual post-state of one Claude Code payload (pure, PRD §3.1).
28
+ *
29
+ * `preState` is the target file's current content, `null` when the file does not
30
+ * exist. Never throws: any input that cannot be classified — a non-object payload,
31
+ * a missing `tool_name`/`tool_input`/`file_path`, an unsatisfiable Edit, a partial
32
+ * MultiEdit, or any tool other than Write/Edit/MultiEdit — resolves to
33
+ * `{ ok: false, reason }` (fail-closed, PRD §4.2).
34
+ */
35
+ export declare function virtualPostState(payload: unknown, preState: string | null): VirtualPostState;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Virtual post-state parser (ADAPTER-02) — computes the file content *after* an
3
+ * Edit/Write/MultiEdit payload would be applied, from `tool_input` alone.
4
+ *
5
+ * Pure computation only — no I/O, no process spawning. Reading the pre-state from
6
+ * disk is the caller's job. Claude vocabulary (`old_string`/`new_string`/
7
+ * `replace_all`) stays confined to this package, never the core.
8
+ */
9
+ import { isPlainObject } from '@polydeukes/core';
10
+ import { parsePayloadEnvelope } from './payload-envelope.js';
11
+ /**
12
+ * Apply one `old_string` → `new_string` substitution to `content` (PRD §3.2).
13
+ *
14
+ * Preconditions mirror the Edit tool's own acceptance rules: non-empty `old_string`,
15
+ * `old_string !== new_string`, and occurrence count exactly 1 (or ≥1 with
16
+ * `replace_all`). Each rejection cause yields a distinguishable reason.
17
+ */
18
+ function applyEdit(content, edit) {
19
+ const { old_string: oldString, new_string: newString, replace_all: replaceAll } = edit;
20
+ if (typeof oldString !== 'string' || typeof newString !== 'string') {
21
+ return { ok: false, reason: 'edit is missing a string old_string/new_string' };
22
+ }
23
+ if (oldString === '') {
24
+ return { ok: false, reason: 'old_string is empty' };
25
+ }
26
+ if (oldString === newString) {
27
+ return { ok: false, reason: 'old_string equals new_string' };
28
+ }
29
+ if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
30
+ return { ok: false, reason: 'replace_all is not a boolean' };
31
+ }
32
+ const occurrences = content.split(oldString).length - 1;
33
+ if (occurrences === 0) {
34
+ return { ok: false, reason: 'old_string does not occur in the pre-state' };
35
+ }
36
+ // Replacer functions insert newString literally — passing it as a plain string would
37
+ // expand $-replacement patterns ($&, $$, $'), diverging from the real Edit tool.
38
+ if (replaceAll === true) {
39
+ return { ok: true, content: content.replaceAll(oldString, () => newString) };
40
+ }
41
+ if (occurrences > 1) {
42
+ return { ok: false, reason: 'old_string occurs more than once without replace_all' };
43
+ }
44
+ return { ok: true, content: content.replace(oldString, () => newString) };
45
+ }
46
+ /**
47
+ * Compute the virtual post-state of one Claude Code payload (pure, PRD §3.1).
48
+ *
49
+ * `preState` is the target file's current content, `null` when the file does not
50
+ * exist. Never throws: any input that cannot be classified — a non-object payload,
51
+ * a missing `tool_name`/`tool_input`/`file_path`, an unsatisfiable Edit, a partial
52
+ * MultiEdit, or any tool other than Write/Edit/MultiEdit — resolves to
53
+ * `{ ok: false, reason }` (fail-closed, PRD §4.2).
54
+ */
55
+ export function virtualPostState(payload, preState) {
56
+ const envelope = parsePayloadEnvelope(payload);
57
+ if (envelope.ok !== true) {
58
+ return { ok: false, reason: envelope.reason };
59
+ }
60
+ const { toolName, toolInput } = envelope;
61
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'MultiEdit') {
62
+ return { ok: false, reason: `tool ${toolName} has no computable post-state` };
63
+ }
64
+ const filePath = toolInput.file_path;
65
+ if (typeof filePath !== 'string') {
66
+ return { ok: false, reason: 'tool_input is missing a string file_path' };
67
+ }
68
+ if (toolName === 'Write') {
69
+ if (typeof toolInput.content !== 'string') {
70
+ return { ok: false, reason: 'Write tool_input is missing a string content' };
71
+ }
72
+ return { ok: true, value: { filePath, content: toolInput.content } };
73
+ }
74
+ if (toolName === 'Edit') {
75
+ if (preState === null) {
76
+ return { ok: false, reason: 'Edit requires a non-null pre-state' };
77
+ }
78
+ const applied = applyEdit(preState, toolInput);
79
+ if (applied.ok !== true) {
80
+ return { ok: false, reason: applied.reason };
81
+ }
82
+ return { ok: true, value: { filePath, content: applied.content } };
83
+ }
84
+ // MultiEdit — the only tool left after the dispatch filter above.
85
+ const edits = toolInput.edits;
86
+ if (!Array.isArray(edits) || edits.length === 0) {
87
+ return { ok: false, reason: 'MultiEdit tool_input is missing a non-empty edits array' };
88
+ }
89
+ // Real-tool parity: MultiEdit creates a file when there is no pre-state and the
90
+ // FIRST edit's old_string is empty — that edit seeds the content. Anywhere else an
91
+ // empty old_string stays rejected by applyEdit.
92
+ let content;
93
+ let startIndex = 0;
94
+ if (preState === null) {
95
+ const first = edits[0];
96
+ if (!isPlainObject(first) || first.old_string !== '' || typeof first.new_string !== 'string') {
97
+ return {
98
+ ok: false,
99
+ reason: 'MultiEdit requires a non-null pre-state unless the first edit creates the file (empty old_string)',
100
+ };
101
+ }
102
+ content = first.new_string;
103
+ startIndex = 1;
104
+ }
105
+ else {
106
+ content = preState;
107
+ }
108
+ // Sequential application: edit N targets the result of edit N-1. Any failure
109
+ // fails the whole call — a partial result must never leak (PRD §6).
110
+ for (let index = startIndex; index < edits.length; index++) {
111
+ const edit = edits[index];
112
+ if (!isPlainObject(edit)) {
113
+ return { ok: false, reason: `MultiEdit edit at index ${index} is not a non-null object` };
114
+ }
115
+ const applied = applyEdit(content, edit);
116
+ if (applied.ok !== true) {
117
+ return { ok: false, reason: `MultiEdit edit at index ${index} failed: ${applied.reason}` };
118
+ }
119
+ content = applied.content;
120
+ }
121
+ return { ok: true, value: { filePath, content } };
122
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@polydeukes/adapter-claude-code",
3
+ "version": "0.3.0",
4
+ "description": "Polydeukes adapter for Claude Code — up-translates PreToolUse hook payloads into the agent-neutral covenant input IR. Alpha.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/huskyhoochu/polydeukes.git",
9
+ "directory": "packages/adapter-claude-code"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@polydeukes/core": "^0.3.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^24.0.0",
36
+ "typescript": "7.0.2",
37
+ "vitest": "^4.1.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run"
43
+ }
44
+ }