@polydeukes/adapter-claude-code 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -1,22 +1,41 @@
1
- # @polydeukes/adapter-claude-code
1
+ # `@polydeukes/adapter-claude-code`
2
2
 
3
- **한국어** · [English](./README.md)
3
+ [English](./README.md) · **한국어**
4
4
 
5
- > Claude Code 어휘가 번역되어 사라지는 경계입니다. PreToolUse 페이로드는 코어에 닿기 전에 에이전트 중립적인 약속(covenant) 입력 IR로 바뀝니다.
5
+ 어댑터는 Claude Code PreToolUse 페이로드를 약속(covenant) 입력 IR로 변환합니다.
6
+ 우산 패키지가 세션 표면에 주입할 읽기 함수를 만드는 팩터리도 제공합니다.
6
7
 
7
- **알파(alpha) 단계입니다.** 에이전트와 도구의 리터럴은 의도적으로 이 패키지 안에만 삽니다. 이 패키지가 존재하는 이유가 그 리터럴이 코어에 닿지 않게 하는 것이고, 그래서 코어의 에이전트 중립성은 구호가 아니라 시험 가능한 주장으로 남습니다.
8
+ <a id="overview"></a>
9
+ ## 개요
8
10
 
9
- ## 여기 담긴
11
+ 공개 계약 심볼은 다음과 같습니다.
10
12
 
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) 않기 때문입니다. 의존은 코어를 통해서만, 단방향으로 흐릅니다.
13
+ - `runAdapterPath`
14
+ - `sessionSourceReader`
15
+ - `sessionChannelReader`
16
+ - `transcriptPathFromPayload`
17
+ - `transcriptFromJsonlFile`
18
+ - `COMMAND_ARGS`
19
+ - `MUTATING_TOOLS`
20
+ - `SHELL_TOOLS`
17
21
 
18
- 아키텍처 청사진과 설계 근거는 [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
22
+ <a id="examples"></a>
23
+ ## 예제
19
24
 
20
- ## 라이선스
25
+ ```ts
26
+ import { runAdapterPath } from '@polydeukes/adapter-claude-code';
21
27
 
22
- MIT
28
+ const outcome = await runAdapterPath({
29
+ rawPayload: '{}',
30
+ telemetryPath: '.polydeukes/roi.log',
31
+ dispatch: async () => ({ exitCode: 0, results: [] }),
32
+ });
33
+ ```
34
+
35
+ <a id="see-also"></a>
36
+ ## 같이 보기
37
+
38
+ - [`@polydeukes/adapter-claude-code` 패키지
39
+ 레퍼런스](../../docs/reference/packages/adapter-claude-code.ko.md)
40
+ - [`polydeukes/claude-code`](../../docs/reference/packages/polydeukes.ko.md#polydeukes-entry-points)
41
+ - [`@polydeukes/covenant`](../../docs/reference/packages/covenant.ko.md)
package/README.md CHANGED
@@ -1,22 +1,41 @@
1
- # @polydeukes/adapter-claude-code
1
+ # `@polydeukes/adapter-claude-code`
2
2
 
3
- **English** · [한국어](https://github.com/huskyhoochu/polydeukes/blob/main/packages/adapter-claude-code/README.ko.md)
3
+ **English** · [한국어](./README.ko.md)
4
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.
5
+ This adapter translates Claude Code PreToolUse payloads into covenant input IR and provides the
6
+ session-side reader factories the umbrella injects.
6
7
 
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
+ <a id="overview"></a>
9
+ ## Overview
8
10
 
9
- ## What lives here
11
+ Public contract symbols include:
10
12
 
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.
13
+ - `runAdapterPath`
14
+ - `sessionSourceReader`
15
+ - `sessionChannelReader`
16
+ - `transcriptPathFromPayload`
17
+ - `transcriptFromJsonlFile`
18
+ - `COMMAND_ARGS`
19
+ - `MUTATING_TOOLS`
20
+ - `SHELL_TOOLS`
17
21
 
18
- See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture blueprint and design rationale.
22
+ <a id="examples"></a>
23
+ ## Examples
19
24
 
20
- ## License
25
+ ```ts
26
+ import { runAdapterPath } from '@polydeukes/adapter-claude-code';
21
27
 
22
- MIT
28
+ const outcome = await runAdapterPath({
29
+ rawPayload: '{}',
30
+ telemetryPath: '.polydeukes/roi.log',
31
+ dispatch: async () => ({ exitCode: 0, results: [] }),
32
+ });
33
+ ```
34
+
35
+ <a id="see-also"></a>
36
+ ## See also
37
+
38
+ - [`@polydeukes/adapter-claude-code` package
39
+ reference](../../docs/reference/packages/adapter-claude-code.md)
40
+ - [`polydeukes/claude-code`](../../docs/reference/packages/polydeukes.md#polydeukes-entry-points)
41
+ - [`@polydeukes/covenant`](../../docs/reference/packages/covenant.md)
package/dist/index.d.ts CHANGED
@@ -2,14 +2,13 @@
2
2
  * @polydeukes/adapter-claude-code — up-translates Claude Code PreToolUse hook
3
3
  * payloads into the agent-neutral covenant input IR.
4
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.
5
+ * Pre-alpha. Translation plus this surface's supply bodies the readers a composition root
6
+ * injects, which open files and spawn nothing. Agent and tool literals live here by design:
7
+ * this package is the boundary where Claude Code's vocabulary is translated away before it
8
+ * reaches the core.
8
9
  */
9
- export { collectFileChanges } from './file-changes.js';
10
- export { evaluatePrecedent } from './precedent.js';
11
- export { type DispatchOutcome, runAdapterPath } from './run-adapter-path.js';
12
- export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, transcriptPathFromPayload, } from './session-vocabulary.js';
13
- export { transcriptFromJsonl, transcriptFromJsonlFile } from './transcript.js';
14
- export { buildCovenantInput, type ClaudePreToolUsePayload, type TranslatedEvent, translateEvent, } from './up-translate.js';
15
- export { type VirtualPostState, virtualPostState } from './virtual-post-state.js';
10
+ export { type AdapterPathOutcome, type DispatchAdapterView, type RunAdapterPathSpec, runAdapterPath, } from './run-adapter-path.ts';
11
+ export { type SessionChannelReaderSpec, sessionChannelReader, } from './session-channel-reader.ts';
12
+ export { type SessionSourceReaderSpec, sessionSourceReader } from './session-source-reader.ts';
13
+ export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, type TranscriptPathFromPayloadSpec, transcriptPathFromPayload, } from './session-vocabulary.ts';
14
+ export { type TranscriptFromJsonlFileSpec, transcriptFromJsonlFile } from './transcript.ts';
package/dist/index.js CHANGED
@@ -2,14 +2,13 @@
2
2
  * @polydeukes/adapter-claude-code — up-translates Claude Code PreToolUse hook
3
3
  * payloads into the agent-neutral covenant input IR.
4
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.
5
+ * Pre-alpha. Translation plus this surface's supply bodies the readers a composition root
6
+ * injects, which open files and spawn nothing. Agent and tool literals live here by design:
7
+ * this package is the boundary where Claude Code's vocabulary is translated away before it
8
+ * reaches the core.
8
9
  */
9
- export { collectFileChanges } from './file-changes.js';
10
- export { evaluatePrecedent } from './precedent.js';
11
- export { runAdapterPath } from './run-adapter-path.js';
10
+ export { runAdapterPath, } from './run-adapter-path.js';
11
+ export { sessionChannelReader, } from './session-channel-reader.js';
12
+ export { sessionSourceReader } from './session-source-reader.js';
12
13
  export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, transcriptPathFromPayload, } from './session-vocabulary.js';
13
- export { transcriptFromJsonl, transcriptFromJsonlFile } from './transcript.js';
14
- export { buildCovenantInput, translateEvent, } from './up-translate.js';
15
- export { virtualPostState } from './virtual-post-state.js';
14
+ export { transcriptFromJsonlFile } from './transcript.js';
@@ -3,21 +3,42 @@
3
3
  *
4
4
  * Composes translation → injected dispatch → funnel-supplement recording so every
5
5
  * adapter-path call leaves exactly one telemetry row when summed with downstream
6
- * records. I/O lives here and only here — the translate layer (index.ts) stays pure.
6
+ * records. I/O lives here and only here — the translation modules stay pure.
7
7
  */
8
+ import { type DispatchOutcome } from '@polydeukes/core';
8
9
  /**
9
- * `DispatchOutcome` — structural mirror of the dispatcher's return.
10
+ * The part of a dispatch result this path reads a structural view, not a second protocol.
10
11
  *
11
- * Deliberately declared here instead of imported: dependencies are one-way (adapter
12
- * core only), so the covenant package is never imported. Contract drift is caught by
13
- * the assembler's typecheck when the real dispatcher is bound to the seam.
12
+ * Derived from core's `DispatchOutcome` by `Pick`, so it cannot drift from the protocol type:
13
+ * a field renamed there stops compiling here. It is narrower on purpose the entries carry
14
+ * a telemetry word this path never reads, and demanding it would make every dispatcher the
15
+ * adapter accepts name a core type its own consumers may not be able to resolve.
14
16
  */
15
- export type DispatchOutcome = {
17
+ export type DispatchAdapterView = {
18
+ exitCode: DispatchOutcome['exitCode'];
19
+ results: readonly unknown[];
20
+ };
21
+ /** {@link runAdapterPath} input — one payload, where to record, and the dispatch seam. */
22
+ export type RunAdapterPathSpec = {
23
+ /** Raw hook stdin — one PreToolUse payload as a JSON string. */
24
+ rawPayload: string;
25
+ /** Where adapter-level records append. */
26
+ telemetryPath: string;
27
+ /**
28
+ * Injected dispatch seam — the assembler binds the real dispatcher here.
29
+ *
30
+ * Typed by what this path reads, not by what the dispatcher returns: the supplement rule
31
+ * below needs the exit code and whether any covenant produced an entry. A dispatcher
32
+ * carrying more per entry — core's `DispatchOutcome`, which adds the telemetry word the
33
+ * wrapper already recorded — satisfies this structurally, so binding one costs no cast.
34
+ */
35
+ dispatch: (stdinPayload: string) => Promise<DispatchAdapterView>;
36
+ /** Label for adapter-level records. Default: 'adapter-claude-code'. */
37
+ adapterLabel?: string;
38
+ };
39
+ /** {@link runAdapterPath} result — the exit code the hook process leaves with. */
40
+ export type AdapterPathOutcome = {
16
41
  exitCode: 0 | 2;
17
- results: {
18
- label: string;
19
- exitCode: 0 | 2;
20
- }[];
21
42
  };
22
43
  /**
23
44
  * Run one PreToolUse payload through the adapter path.
@@ -29,14 +50,4 @@ export type DispatchOutcome = {
29
50
  * `exitCode 0 && results.length 0 → one adapter passed record`; every other outcome
30
51
  * appends nothing because downstream already recorded, so nothing double-counts.
31
52
  */
32
- export declare function runAdapterPath(spec: {
33
- /** Raw hook stdin — one PreToolUse payload as a JSON string. */
34
- rawPayload: string;
35
- telemetryPath: string;
36
- /** Injected dispatch seam — the assembler binds the real dispatcher here. */
37
- dispatch: (stdinPayload: string) => Promise<DispatchOutcome>;
38
- /** Label for adapter-level records. Default: 'adapter-claude-code'. */
39
- adapterLabel?: string;
40
- }): Promise<{
41
- exitCode: 0 | 2;
42
- }>;
53
+ export declare function runAdapterPath(spec: RunAdapterPathSpec): Promise<AdapterPathOutcome>;
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Composes translation → injected dispatch → funnel-supplement recording so every
5
5
  * adapter-path call leaves exactly one telemetry row when summed with downstream
6
- * records. I/O lives here and only here — the translate layer (index.ts) stays pure.
6
+ * records. I/O lives here and only here — the translation modules stay pure.
7
7
  */
8
8
  import { readFileSync } from 'node:fs';
9
- import { appendRecordFailOpen, EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from '@polydeukes/core';
9
+ import { appendRecordFailOpen, EXIT_BREAK_BLOCKING, EXIT_UPHOLD, } from '@polydeukes/core';
10
10
  import { collectFileChanges } from './file-changes.js';
11
11
  import { buildCovenantInput } from './up-translate.js';
12
12
  /** Default label for adapter-level telemetry records. */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The session surface's supply body for the spawn sidecar channel — the subagent records
3
+ * this host keeps beside the session's own transcript.
4
+ */
5
+ import type { ChannelReader } from '@polydeukes/core';
6
+ /** {@link sessionChannelReader} input — the session's transcript, when the host named one. */
7
+ export type SessionChannelReaderSpec = {
8
+ transcriptPath?: string;
9
+ };
10
+ /**
11
+ * A reader over the spawn sidecar: a channel kind in, the spawn-record list as JSON text or
12
+ * absence out.
13
+ *
14
+ * A transcript at `<dir>/<sessionId>.jsonl` keeps its subagent records at
15
+ * `<dir>/<sessionId>/subagents/agent-*.meta.json`, one object per file. The three answers
16
+ * are three facts: the parsed records as one JSON array, `'[]'` when the directory is there
17
+ * and holds no record (the channel observed no spawn), and `undefined` when there is no
18
+ * channel at all. A record that will not parse shrinks the evidence rather than poisoning
19
+ * it — failing the whole channel would let one corrupt file erase a spawn that happened.
20
+ */
21
+ export declare function sessionChannelReader(spec: SessionChannelReaderSpec): ChannelReader;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The session surface's supply body for the spawn sidecar channel — the subagent records
3
+ * this host keeps beside the session's own transcript.
4
+ */
5
+ import { readdirSync, readFileSync } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join } from 'node:path';
7
+ /** The channel kind this surface carries; any other kind is absent. */
8
+ const SIDECAR = 'sidecar';
9
+ /** The file extension the transcript is named with, stripped to get the session id. */
10
+ const TRANSCRIPT_SUFFIX = '.jsonl';
11
+ /** One subagent record per file, under the session's own directory beside the transcript. */
12
+ const SUBAGENTS_DIR = 'subagents';
13
+ const META_PREFIX = 'agent-';
14
+ const META_SUFFIX = '.meta.json';
15
+ /**
16
+ * A reader over the spawn sidecar: a channel kind in, the spawn-record list as JSON text or
17
+ * absence out.
18
+ *
19
+ * A transcript at `<dir>/<sessionId>.jsonl` keeps its subagent records at
20
+ * `<dir>/<sessionId>/subagents/agent-*.meta.json`, one object per file. The three answers
21
+ * are three facts: the parsed records as one JSON array, `'[]'` when the directory is there
22
+ * and holds no record (the channel observed no spawn), and `undefined` when there is no
23
+ * channel at all. A record that will not parse shrinks the evidence rather than poisoning
24
+ * it — failing the whole channel would let one corrupt file erase a spawn that happened.
25
+ */
26
+ export function sessionChannelReader(spec) {
27
+ return (kind) => {
28
+ if (kind !== SIDECAR)
29
+ return undefined;
30
+ const { transcriptPath } = spec;
31
+ // The path is the host's own fact, and every real host hands an absolute one. A
32
+ // relative or empty spelling would resolve the sidecar against the hook's cwd, where
33
+ // a checked-out `subagents/` directory could pass its records off as this session's
34
+ // spawn evidence — so anything non-absolute is channel absence.
35
+ if (transcriptPath === undefined || !isAbsolute(transcriptPath))
36
+ return undefined;
37
+ const sessionId = basename(transcriptPath, TRANSCRIPT_SUFFIX);
38
+ const dir = join(dirname(transcriptPath), sessionId, SUBAGENTS_DIR);
39
+ let entries;
40
+ try {
41
+ // Directory entries rather than names: a host can leave a directory whose name matches
42
+ // the record pattern, and reading it as a file would take the whole channel down.
43
+ entries = readdirSync(dir, { withFileTypes: true })
44
+ .filter((entry) => entry.isFile() &&
45
+ entry.name.startsWith(META_PREFIX) &&
46
+ entry.name.endsWith(META_SUFFIX))
47
+ .map((entry) => entry.name)
48
+ .sort();
49
+ }
50
+ catch (error) {
51
+ // Only "no such directory" is channel absence. Anything else — a permission refusal
52
+ // above all — throws, so it reaches the root's fail-closed path instead of passing
53
+ // for a session that never spawned.
54
+ const { code } = error;
55
+ if (code === 'ENOENT' || code === 'ENOTDIR')
56
+ return undefined;
57
+ throw error;
58
+ }
59
+ const records = [];
60
+ for (const name of entries) {
61
+ let text;
62
+ try {
63
+ text = readFileSync(join(dir, name), 'utf-8');
64
+ }
65
+ catch (error) {
66
+ // A record deleted between the listing and the read is one fewer witness; a read
67
+ // the host refused is the same fail-closed fact as the directory case above.
68
+ if (error.code === 'ENOENT')
69
+ continue;
70
+ throw error;
71
+ }
72
+ try {
73
+ records.push(JSON.parse(text));
74
+ }
75
+ catch {
76
+ // A record this host wrote half-way is one fewer witness, never a failed channel.
77
+ }
78
+ }
79
+ return JSON.stringify(records);
80
+ };
81
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The session surface's supply body for named file sources — the working tree as this
3
+ * surface observes it.
4
+ */
5
+ import type { SourceReader } from '@polydeukes/core';
6
+ /** {@link sessionSourceReader} input — the repository the paths are relative to. */
7
+ export type SessionSourceReaderSpec = {
8
+ repoRoot: string;
9
+ };
10
+ /**
11
+ * A reader over the working tree under `repoRoot`: repo-relative path in, the file's text or
12
+ * absence out.
13
+ *
14
+ * Absence covers every shape the disk can hold under a planned path that is not a readable
15
+ * text — no entry at all, a directory, a path whose parent is a file, and bytes carrying a
16
+ * NUL, which no declaration can parse and which a utf-8 decode would hand on as a lossy
17
+ * string. Everything else throws, so a permission refusal fails the run closed rather than
18
+ * passing for a file that is not there.
19
+ */
20
+ export declare function sessionSourceReader(spec: SessionSourceReaderSpec): SourceReader;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The session surface's supply body for named file sources — the working tree as this
3
+ * surface observes it.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ /**
8
+ * A reader over the working tree under `repoRoot`: repo-relative path in, the file's text or
9
+ * absence out.
10
+ *
11
+ * Absence covers every shape the disk can hold under a planned path that is not a readable
12
+ * text — no entry at all, a directory, a path whose parent is a file, and bytes carrying a
13
+ * NUL, which no declaration can parse and which a utf-8 decode would hand on as a lossy
14
+ * string. Everything else throws, so a permission refusal fails the run closed rather than
15
+ * passing for a file that is not there.
16
+ */
17
+ export function sessionSourceReader(spec) {
18
+ return (path) => {
19
+ let bytes;
20
+ try {
21
+ bytes = readFileSync(join(spec.repoRoot, path));
22
+ }
23
+ catch (error) {
24
+ const { code } = error;
25
+ if (code === 'ENOENT' || code === 'EISDIR' || code === 'ENOTDIR')
26
+ return undefined;
27
+ throw error;
28
+ }
29
+ return bytes.includes(0) ? undefined : bytes.toString('utf-8');
30
+ };
31
+ }
@@ -18,6 +18,10 @@ export declare const MUTATING_TOOLS: string[];
18
18
  export declare const SHELL_TOOLS: string[];
19
19
  /** `tool_input` keys a shell tool's command line travels in. */
20
20
  export declare const COMMAND_ARGS: string[];
21
+ /** {@link transcriptPathFromPayload} input — one raw PreToolUse payload as a JSON string. */
22
+ export type TranscriptPathFromPayloadSpec = {
23
+ rawPayload: string;
24
+ };
21
25
  /**
22
26
  * Read the live transcript path out of a raw PreToolUse payload.
23
27
  *
@@ -28,4 +32,4 @@ export declare const COMMAND_ARGS: string[];
28
32
  * witness valve rather than opening it. A payload this function
29
33
  * cannot parse is still dispatched — `runAdapterPath` owns that verdict.
30
34
  */
31
- export declare function transcriptPathFromPayload(rawPayload: string): string | undefined;
35
+ export declare function transcriptPathFromPayload(spec: TranscriptPathFromPayloadSpec): string | undefined;
@@ -29,10 +29,10 @@ export const COMMAND_ARGS = ['command'];
29
29
  * witness valve rather than opening it. A payload this function
30
30
  * cannot parse is still dispatched — `runAdapterPath` owns that verdict.
31
31
  */
32
- export function transcriptPathFromPayload(rawPayload) {
32
+ export function transcriptPathFromPayload(spec) {
33
33
  let parsed;
34
34
  try {
35
- parsed = JSON.parse(rawPayload);
35
+ parsed = JSON.parse(spec.rawPayload);
36
36
  }
37
37
  catch {
38
38
  return undefined;
@@ -16,18 +16,22 @@ import { type CanonicalTranscript } from '@polydeukes/core';
16
16
  * silently — a broken line never aborts the rest of the scan and never throws.
17
17
  */
18
18
  export declare function transcriptFromJsonl(text: string): CanonicalTranscript;
19
+ /** {@link transcriptFromJsonlFile} input — the transcript file to read. */
20
+ export type TranscriptFromJsonlFileSpec = {
21
+ path: string;
22
+ };
19
23
  /**
20
24
  * Read a transcript file and parse it.
21
25
  *
22
26
  * ANY read failure — missing file, permission, directory — answers `undefined`, never a
23
27
  * throw. It is deliberately NOT an empty transcript: the two are different facts, and
24
28
  * 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
29
+ * nothing yet, and judging against it is correct. An unreadable one is no session channel
30
+ * at all, so a history declaration must skip rather than demand evidence from a session
27
31
  * nobody can read — while the witness valve reads the same absence and stays shut, leaving
28
32
  * a dead end with no message naming the cause.
29
33
  *
30
34
  * Either way the valve turns off, never open: `undefined` leaves the dispatcher on its
31
35
  * `noopTranscript` default.
32
36
  */
33
- export declare function transcriptFromJsonlFile(path: string): CanonicalTranscript | undefined;
37
+ export declare function transcriptFromJsonlFile(spec: TranscriptFromJsonlFileSpec): CanonicalTranscript | undefined;
@@ -34,32 +34,6 @@ function toUserMessage(entry) {
34
34
  timestampMs: Number.isFinite(parsed) ? parsed : undefined,
35
35
  };
36
36
  }
37
- /**
38
- * Extract subagent invocations from one entry.
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
37
  /**
64
38
  * Extract tool calls from one entry.
65
39
  *
@@ -129,7 +103,6 @@ function toToolResults(entry) {
129
103
  */
130
104
  export function transcriptFromJsonl(text) {
131
105
  const userMessages = [];
132
- const subagentInvocations = [];
133
106
  const observedCalls = [];
134
107
  const outcomes = new Map();
135
108
  for (const line of text.split('\n')) {
@@ -147,7 +120,6 @@ export function transcriptFromJsonl(text) {
147
120
  if (message !== undefined) {
148
121
  userMessages.push(message);
149
122
  }
150
- subagentInvocations.push(...toSubagentInvocations(entry));
151
123
  observedCalls.push(...toToolCalls(entry));
152
124
  for (const result of toToolResults(entry)) {
153
125
  // First result wins. Real transcripts carry no duplicate reference within one file
@@ -169,9 +141,6 @@ export function transcriptFromJsonl(text) {
169
141
  // call's nested args — so a consumer mutating a result cannot corrupt what later
170
142
  // queries read (the same alias-safety contract the core transcriptFromInput upholds).
171
143
  return {
172
- findSubagentInvocations: (kind) => subagentInvocations
173
- .filter((invocation) => kind === undefined || invocation.kind === kind)
174
- .map((invocation) => ({ ...invocation })),
175
144
  findUserMessages: () => userMessages.map((message) => ({ ...message })),
176
145
  findToolCalls: (name) => toolCalls
177
146
  .filter((call) => name === undefined || call.name === name)
@@ -184,18 +153,18 @@ export function transcriptFromJsonl(text) {
184
153
  * ANY read failure — missing file, permission, directory — answers `undefined`, never a
185
154
  * throw. It is deliberately NOT an empty transcript: the two are different facts, and
186
155
  * 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
156
+ * nothing yet, and judging against it is correct. An unreadable one is no session channel
157
+ * at all, so a history declaration must skip rather than demand evidence from a session
189
158
  * nobody can read — while the witness valve reads the same absence and stays shut, leaving
190
159
  * a dead end with no message naming the cause.
191
160
  *
192
161
  * Either way the valve turns off, never open: `undefined` leaves the dispatcher on its
193
162
  * `noopTranscript` default.
194
163
  */
195
- export function transcriptFromJsonlFile(path) {
164
+ export function transcriptFromJsonlFile(spec) {
196
165
  let text;
197
166
  try {
198
- text = readFileSync(path, 'utf-8');
167
+ text = readFileSync(spec.path, 'utf-8');
199
168
  }
200
169
  catch {
201
170
  return undefined;
@@ -23,6 +23,7 @@ export type ClaudePreToolUsePayload = {
23
23
  session_id?: string;
24
24
  transcript_path?: string;
25
25
  cwd?: string;
26
+ agent_type?: string;
26
27
  tool_name: string;
27
28
  tool_input: Record<string, unknown>;
28
29
  };
@@ -66,6 +67,8 @@ export declare function translateEvent(payload: unknown): TranslatedEvent;
66
67
  * always `[]` — witness evidence arrives through the transcript, not this path. If any
67
68
  * payload fails classification the whole build fails closed with the blocking exit
68
69
  * code — a silent drop would be a bypass vector.
70
+ *
71
+ * `actor` comes from {@link actorOf} over the first payload: one observation has one actor.
69
72
  */
70
73
  export declare function buildCovenantInput(payloads: unknown[]): {
71
74
  ok: true;
@@ -10,7 +10,7 @@
10
10
  * the barrel is an initialization cycle. The barrel re-exports this
11
11
  * module, so every consumer outside the package still reaches it at the same path.
12
12
  */
13
- import { EXIT_BREAK_BLOCKING } from '@polydeukes/core';
13
+ import { EXIT_BREAK_BLOCKING, isPlainObject } from '@polydeukes/core';
14
14
  import { parsePayloadEnvelope } from './payload-envelope.js';
15
15
  /**
16
16
  * Up-translate one Claude Code payload into an IR fragment (pure).
@@ -33,6 +33,23 @@ export function translateEvent(payload) {
33
33
  }
34
34
  return { ok: true, kind: 'toolCall', value: { name: toolName, args: toolInput } };
35
35
  }
36
+ /**
37
+ * The actor one payload proves (pure).
38
+ *
39
+ * The host writes `agent_type` at the envelope's top level when the hook fires inside a
40
+ * subagent call, so a non-empty string there is the subagent kind and anything else — an
41
+ * absent field, a non-string, the empty string — is the main session, the positive `{}`.
42
+ * `tool_input` is the agent's own arguments and is never read here: a call could then name
43
+ * any actor it liked.
44
+ */
45
+ function actorOf(payload) {
46
+ if (!isPlainObject(payload))
47
+ return {};
48
+ const agentType = payload.agent_type;
49
+ if (typeof agentType !== 'string' || agentType === '')
50
+ return {};
51
+ return { agentType };
52
+ }
36
53
  /**
37
54
  * Fold a sequence of payloads into one {@link CovenantInput} (pure).
38
55
  *
@@ -40,9 +57,17 @@ export function translateEvent(payload) {
40
57
  * always `[]` — witness evidence arrives through the transcript, not this path. If any
41
58
  * payload fails classification the whole build fails closed with the blocking exit
42
59
  * code — a silent drop would be a bypass vector.
60
+ *
61
+ * `actor` comes from {@link actorOf} over the first payload: one observation has one actor.
43
62
  */
44
63
  export function buildCovenantInput(payloads) {
45
- const input = { toolCalls: [], subagentSpawns: [], userMessages: [] };
64
+ const input = {
65
+ toolCalls: [],
66
+ subagentSpawns: [],
67
+ userMessages: [],
68
+ // No payload is no observation, so there is no actor to prove — not the main session.
69
+ ...(payloads.length > 0 && { actor: actorOf(payloads[0]) }),
70
+ };
46
71
  for (let index = 0; index < payloads.length; index++) {
47
72
  const translated = translateEvent(payloads[index]);
48
73
  if (translated.ok !== true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polydeukes/adapter-claude-code",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Polydeukes adapter for Claude Code — up-translates PreToolUse hook payloads into the agent-neutral covenant input IR. Alpha.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,13 +28,14 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "dependencies": {
32
- "@polydeukes/core": "^0.5.0"
33
- },
34
31
  "devDependencies": {
35
32
  "@types/node": "^24.0.0",
36
33
  "typescript": "7.0.2",
37
- "vitest": "^4.1.0"
34
+ "vitest": "^4.1.0",
35
+ "@polydeukes/core": "^0.6.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@polydeukes/core": "^0.6.1"
38
39
  },
39
40
  "scripts": {
40
41
  "build": "tsc -p tsconfig.build.json",
@@ -1,31 +0,0 @@
1
- /**
2
- * Adapter-owned precedent evidence evaluator.
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.
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: a call the
21
- * covenant blocked, one the human refused, and one that simply failed carry the same
22
- * 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;
package/dist/precedent.js DELETED
@@ -1,57 +0,0 @@
1
- /**
2
- * Adapter-owned precedent evidence evaluator.
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.
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: a call the
20
- * covenant blocked, one the human refused, and one that simply failed carry the same
21
- * 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
- }