@polydeukes/adapter-claude-code 0.5.0 → 0.6.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/README.ko.md +25 -7
- package/README.md +44 -11
- package/dist/index.d.ts +9 -10
- package/dist/index.js +8 -9
- package/dist/run-adapter-path.d.ts +32 -21
- package/dist/run-adapter-path.js +2 -2
- package/dist/session-channel-reader.d.ts +21 -0
- package/dist/session-channel-reader.js +81 -0
- package/dist/session-source-reader.d.ts +20 -0
- package/dist/session-source-reader.js +31 -0
- package/dist/session-vocabulary.d.ts +5 -1
- package/dist/session-vocabulary.js +2 -2
- package/dist/transcript.d.ts +7 -3
- package/dist/transcript.js +4 -35
- package/dist/up-translate.d.ts +3 -0
- package/dist/up-translate.js +27 -2
- package/package.json +6 -5
- package/dist/precedent.d.ts +0 -31
- package/dist/precedent.js +0 -57
package/README.ko.md
CHANGED
|
@@ -4,16 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
> Claude Code의 어휘가 번역되어 사라지는 경계입니다. PreToolUse 훅 페이로드는 코어에 닿기 전에 에이전트 중립적인 약속(covenant) 입력 IR로 바뀝니다.
|
|
6
6
|
|
|
7
|
-
**알파(alpha) 단계입니다.** 에이전트와 도구의 리터럴은 의도적으로 이 패키지 안에만 삽니다. 이 패키지가 존재하는 이유가 그 리터럴이 코어에 닿지 않게 하는 것이고,
|
|
7
|
+
**알파(alpha) 단계입니다.** 에이전트와 도구의 리터럴은 의도적으로 이 패키지 안에만 삽니다. 이 패키지가 존재하는 이유가 그 리터럴이 코어에 닿지 않게 하는 것이고,
|
|
8
|
+
그래서 코어의 에이전트 중립성은 구호가 아니라 시험 가능한 주장으로 남습니다.
|
|
8
9
|
|
|
9
10
|
## 여기 담긴 것
|
|
10
11
|
|
|
11
|
-
- **페이로드 번역.** 날것의 PreToolUse 페이로드가 `CovenantInput`(도구 호출과 서브에이전트 스폰)으로 바뀝니다. 서브에이전트 유형을 실은 `Task`
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
12
|
+
- **페이로드 번역.** 날것의 PreToolUse 페이로드가 `CovenantInput`(도구 호출과 서브에이전트 스폰)으로 바뀝니다. 서브에이전트 유형을 실은 `Task`
|
|
13
|
+
호출은 스폰으로 대응되고, 분류할 수 없는 페이로드는 번역 실패입니다. 실패는 추측으로 대체되는 대신 차단됩니다(fail-closed).
|
|
14
|
+
- **가상 사후 상태(virtual post-state).** `Edit`·`Write`·`MultiEdit`가 적용되면 파일이 어떤 내용이 될지를 디스크를 건드리지 않고
|
|
15
|
+
계산합니다. 내용을 보는 약속은 지금의 파일이 아니라 제안된 결과를 판정합니다. 여러 편집의 순차 적용과 파일 생성 관례까지 계산에 들어갑니다.
|
|
16
|
+
- **파일 변경 증거.** 디스크의 편집 전 상태와 가상 사후 상태를 짝지어 판별 유니온(discriminated union) 증거(편집 전 상태가 없으면 `create`,
|
|
17
|
+
있으면 `modify`)로 만들고, 변이 도구 호출 자신의 `fileChange` 필드에 싣습니다. 규율(discipline) 판정이 소비하는 증거가 이것입니다. 사후 상태가
|
|
18
|
+
성립하지 않으면 아무것도 내지 않고(같은 편집은 실제 도구도 거부합니다), 비변이 호출에 증거를 날조하지 않습니다.
|
|
19
|
+
- **대화 기록 공급자.** `transcriptFromJsonlFile`이 세션 JSONL 파일을 `CanonicalTranscript`로 바꿉니다. 시간제 증인(TTL
|
|
20
|
+
witness)의 실제 데이터 원천이며, 사람이 직접 친 것으로 확인된 메시지만 받아들이므로 AI가 자기 증언을 합성할 길이 없습니다. `findToolCalls`에도 답해
|
|
21
|
+
`tool_use` 블록에서 도구 호출을 뽑아냅니다. 호출의 `input`이 평면 객체가 아니면 블록은 남기고 `args`만 비우는데, 호출이 있었다는 사실 자체가 증거이기
|
|
22
|
+
때문입니다. 읽기에 실패하면 빈 대화 기록이 아니라 `undefined`로 답합니다. 빈 세션은 아직 아무 말도 하지 않은 세션이라 판정 대상이고, 읽히지 않는 세션은 증거
|
|
23
|
+
통로 자체가 없어서 건너뜁니다. 어느 쪽이든 밸브는 닫히지, 열리지 않습니다.
|
|
24
|
+
- **세션 공급 리더.** `sessionSourceReader({ repoRoot })`는 이 표면의 파일 리더입니다(작업 트리 위의 `SourceReader` —
|
|
25
|
+
항목 없음 · 디렉터리 · NUL 바이트 같은 부재는 `undefined`로 답하고, 권한 거부는 던집니다).
|
|
26
|
+
`sessionChannelReader({ transcriptPath })`는 이 표면의 스폰 사이드카 채널입니다. 이 호스트가 대화 기록 옆
|
|
27
|
+
`<dir>/<세션ID>/subagents/agent-*.meta.json`에 남기는 서브에이전트 기록을 파일명 정렬 순서의 JSON 배열 하나로 답합니다.
|
|
28
|
+
`'[]'`는 스폰을 관측하지 못한 채널이고 `undefined`는 채널 자체가 없는 것 — 서로 다른 두 사실이며, 선언의 `supply` 정책이
|
|
29
|
+
처분하는 것은 둘째뿐입니다.
|
|
30
|
+
이 표면이 파싱하는 대화 기록(transcript)은 선언의 소스이기도 합니다(`{ transcript: true }`). 약속(covenant) 패키지가
|
|
31
|
+
공급 시점에 스냅샷으로 펴므로 이 어댑터는 그것을 위한 리더를 더하지 않습니다.
|
|
32
|
+
- **텔레메트리 배선.** `runAdapterPath`가 깔때기 전체를 끝까지 실행합니다. 날 페이로드에서 번역(실패는 차단 1행을 기록하고 `2`로 종료), 주입된 이음새를
|
|
33
|
+
통한 판정, 깔때기 완결까지 이어져 호출마다 정확히 1행이 남습니다. 증언(witnessed)도 예외가 아닙니다. 판정 이음새를 주입으로 받는 이유는 이 패키지가
|
|
34
|
+
covenant 패키지를 직접 가져오지(import) 않기 때문입니다. 의존은 코어를 통해서만, 단방향으로 흐릅니다.
|
|
17
35
|
|
|
18
36
|
아키텍처 청사진과 설계 근거는 [프로젝트 저장소](https://github.com/huskyhoochu/polydeukes)에 있습니다.
|
|
19
37
|
|
package/README.md
CHANGED
|
@@ -1,21 +1,54 @@
|
|
|
1
1
|
# @polydeukes/adapter-claude-code
|
|
2
2
|
|
|
3
|
-
**English** ·
|
|
3
|
+
**English** ·
|
|
4
|
+
[한국어](https://github.com/huskyhoochu/polydeukes/blob/main/packages/adapter-claude-code/README.ko.md)
|
|
4
5
|
|
|
5
|
-
> The boundary where Claude Code's vocabulary is translated away. PreToolUse hook payloads become
|
|
6
|
+
> The boundary where Claude Code's vocabulary is translated away. PreToolUse hook payloads become
|
|
7
|
+
the agent-neutral covenant input IR before anything reaches the core.
|
|
6
8
|
|
|
7
|
-
**Alpha.** Agent and tool literals live *here* by design — this package exists so they never reach
|
|
9
|
+
**Alpha.** Agent and tool literals live *here* by design — this package exists so they never reach
|
|
10
|
+
the core, which is how the core's agent-neutrality stays a testable claim rather than a slogan.
|
|
8
11
|
|
|
9
12
|
## What lives here
|
|
10
13
|
|
|
11
|
-
- **Payload up-translation** — a raw PreToolUse payload becomes a `CovenantInput` (tool calls and
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
- **Payload up-translation** — a raw PreToolUse payload becomes a `CovenantInput` (tool calls and
|
|
15
|
+
subagent spawns). A `Task` call carrying a subagent type maps to a spawn; a payload that cannot be
|
|
16
|
+
classified is a translation *failure*, and failures block (fail-closed) instead of degrading into
|
|
17
|
+
a guess.
|
|
18
|
+
- **Virtual post-state** — computes what a file *would* contain after an `Edit`/`Write`/`MultiEdit`
|
|
19
|
+
applies, without touching disk. Content-aware covenants judge the proposed result, not the file as
|
|
20
|
+
it currently is — including sequential multi-edit application and file-creation conventions.
|
|
21
|
+
- **File-change evidence** — the disk pre-state pairs with the virtual post-state into union
|
|
22
|
+
evidence (`create` when no pre-state exists, `modify` otherwise) attached to the mutating tool
|
|
23
|
+
call's own `fileChange` field — the evidence discipline judgments consume. An unresolvable
|
|
24
|
+
post-state yields nothing (the real tool rejects the same edit), and evidence is never fabricated
|
|
25
|
+
for non-mutating calls.
|
|
26
|
+
- **Transcript provider** — `transcriptFromJsonlFile` turns a session JSONL file into a
|
|
27
|
+
`CanonicalTranscript`, the TTL witness's real data source: only positively-identified human-typed
|
|
28
|
+
messages are admitted, so an AI can never synthesize its own witness. It answers `findToolCalls`
|
|
29
|
+
too, extracting calls from `tool_use` blocks — when a call's `input` is not a flat object the
|
|
30
|
+
block still yields a call with empty `args`, because the existence of the call is itself the
|
|
31
|
+
evidence. A read failure answers `undefined` rather than an empty transcript — an empty session
|
|
32
|
+
has said nothing yet and is judged, an unreadable one is no evidence channel at all and is
|
|
33
|
+
skipped. Either way the valve turns off, never open.
|
|
34
|
+
- **Session supply readers** — `sessionSourceReader({ repoRoot })` is this surface's file
|
|
35
|
+
reader (a `SourceReader` over the working tree: absence — no entry, a directory, NUL bytes —
|
|
36
|
+
answers `undefined`, a permission refusal throws), and `sessionChannelReader({ transcriptPath })`
|
|
37
|
+
is its spawn-sidecar channel: the subagent records this host keeps at
|
|
38
|
+
`<dir>/<sessionId>/subagents/agent-*.meta.json` beside the transcript, answered as one JSON
|
|
39
|
+
array in filename order. `'[]'` is a channel that observed no spawn; `undefined` is no
|
|
40
|
+
channel at all — two different facts, and the declaration's `supply` policy disposes only of
|
|
41
|
+
the second. The transcript this surface parses is also a declaration source (`{ transcript:
|
|
42
|
+
true }`): the covenant flattens it into a snapshot at supply time, so this adapter adds no
|
|
43
|
+
reader for it.
|
|
44
|
+
- **Telemetry wiring** — `runAdapterPath` drives the full funnel: raw payload → translation (a
|
|
45
|
+
failure logs one blocked record and exits `2`) → dispatch through an *injected* seam → funnel
|
|
46
|
+
completion, so exactly one record lands per call, witnessed rows included. The dispatch seam is
|
|
47
|
+
injected because this package never imports the covenant package — dependencies stay one-way,
|
|
48
|
+
through the core only.
|
|
49
|
+
|
|
50
|
+
See the [project repository](https://github.com/huskyhoochu/polydeukes) for the architecture
|
|
51
|
+
blueprint and design rationale.
|
|
19
52
|
|
|
20
53
|
## License
|
|
21
54
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
* vocabulary is translated away before it
|
|
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 {
|
|
10
|
-
export {
|
|
11
|
-
export { type
|
|
12
|
-
export { COMMAND_ARGS, MUTATING_TOOLS, SHELL_TOOLS, transcriptPathFromPayload, } from './session-vocabulary.
|
|
13
|
-
export {
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
* vocabulary is translated away before it
|
|
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 {
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
10
|
+
* The part of a dispatch result this path reads — a structural view, not a second protocol.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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>;
|
package/dist/run-adapter-path.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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;
|
package/dist/transcript.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
* at all, so
|
|
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(
|
|
37
|
+
export declare function transcriptFromJsonlFile(spec: TranscriptFromJsonlFileSpec): CanonicalTranscript | undefined;
|
package/dist/transcript.js
CHANGED
|
@@ -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
|
|
188
|
-
* at all, so
|
|
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(
|
|
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;
|
package/dist/up-translate.d.ts
CHANGED
|
@@ -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;
|
package/dist/up-translate.js
CHANGED
|
@@ -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 = {
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
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.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@polydeukes/core": "^0.6.0"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
41
|
"build": "tsc -p tsconfig.build.json",
|
package/dist/precedent.d.ts
DELETED
|
@@ -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
|
-
}
|