polydeukes 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 CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## 여기 담긴 것
13
13
 
14
- - **`loadConfig(rootDir)`.** config 디스커버리입니다. 주어진 루트 바로 아래에서 정확히 하나의
14
+ - **`loadConfig({ rootDir })`.** config 디스커버리입니다. 주어진 루트 바로 아래에서 정확히 하나의
15
15
  데이터 config(`polydeukes.config` 파일의 yaml·yml·json 형태)를 찾아, 안전 스키마로 해석하고
16
16
  (설정 데이터는 결코 실행되지 않습니다) 코어의 `defineConfig()`에 검증을 맡깁니다. 모든 실패
17
17
  분기는 예외를 던집니다(throw). 조용한 기본값은 금지이고, 발견된 파일은 자기 자신을 보호
@@ -20,8 +20,8 @@
20
20
  pre-commit 판정 러너로, 스테이징 영역의 변경을 `@polydeukes/adapter-git`이 수집해
21
21
  약속(covenant) 입력 IR로 번역하고, 세션 훅이 부르는 바로 그 in-process 판정기로 보냅니다.
22
22
  판정기는 하나, 표면은 여럿입니다. `--worktree`와 `--range <base>..<head>`는 같은 판정을
23
- 작업 트리나 ref 범위에 진단 호출로 돌리며, 증인 프롬프트는 뜨지 않습니다. 맥락족 규율(discipline, `requirePrecedent`)도 다른 규율과
24
- 똑같이 조립되지만, 읽을 세션이 없으므로 스킵 등록이 됩니다. 스테이징 변경과 매치하면 자기
23
+ 작업 트리나 ref 범위에 진단 호출로 돌리며, 증인 프롬프트는 뜨지 않습니다. 세션을 읽는 선언(`precedent`와 다른 이력 기전)도 다른 규율과
24
+ 똑같이 조립되지만, 읽을 세션이 없으므로 자기 `supply: pass`가 처분합니다. 스테이징 변경과 매치하면 자기
25
25
  id와 그 변경을 담은 `skipped` 이벤트를 남기고 커밋은 진행됩니다. 여기서 판정하면 걸리는
26
26
  커밋마다 정당하게 통과할 길 없이 막히고, 아예 걸러내면 게이트가 물러섰다는 사실이 가려집니다.
27
27
  세션 표면이 읽을 transcript를 갖지 못했을 때와 같은 처분입니다. 빈 스테이징은 명시적
@@ -36,6 +36,12 @@
36
36
  방금 등록한 훅 자신이 보호 대상이기 때문입니다. config 철자 둘이 공존하는 트리, 파싱되지 않는
37
37
  settings 파일, 해소되지 않는 패키지는 전부 선행 조건 실패입니다. 반쯤 배선된 트리를 남기는
38
38
  대신 파일을 하나도 만들지 않습니다.
39
+ - **`pdks init grok`.** Grok 세션 표면 설치기입니다. 선행 검사와 공유 스캐폴드는 같고, 이 경로가
40
+ 지는 것은 `.grok/hooks/`입니다(timeout 60인 JSON 등록, Claude 위임자가 없을 때만 위임자 파일).
41
+ Claude 훅 파일이 이미 있으면 JSON command가 그 파일을 가리켜 판정기를 둘 스폰하지 않습니다.
42
+ 나중에 어느 설치기를 다시 돌려도, 설치기가 심은 grok-mjs command는 같은 방식으로 재조준되고,
43
+ JSON matcher는 그 command의 `.claude/settings.json` 항목을 따릅니다. Grok는 command와
44
+ matcher가 모두 같을 때만 등록 둘을 하나로 접기 때문입니다. 이미 열린 Grok 세션은 시작 때 훅 스냅샷을 유지합니다. 증인 밸브는 Grok에서 열리지 않습니다.
39
45
  - **`pdks docs [topic]`.** 오프라인 문서 열람기입니다. 영어 가이드와 레퍼런스 층이 빌드 시점에
40
46
  `dist/docs`로 복사되므로, 소비자의 AI 파트너는 검색 엔진이 색인한 아무 판본이 아니라 판정을
41
47
  수행하는 코드와 함께 실린 문서를 읽습니다. 인자 없이 부르면 토픽 다섯을 열거하는데, 에이전트가
package/README.md CHANGED
@@ -11,7 +11,7 @@ one-way, through the core alone.
11
11
 
12
12
  ## What lives here
13
13
 
14
- - **`loadConfig(rootDir)`** — config discovery. Exactly one root data config (a
14
+ - **`loadConfig({ rootDir })`** — config discovery. Exactly one root data config (a
15
15
  `polydeukes.config` file in yaml, yml, or json form) directly under the given root, parsed with
16
16
  a safe schema (config data is never executable) and validated by the core's `defineConfig()`.
17
17
  Every failure branch throws — silent defaults are forbidden — and the discovered file attaches
@@ -21,8 +21,8 @@ one-way, through the core alone.
21
21
  translated into the covenant input IR, and dispatched through the very in-process judges the
22
22
  session hook calls — one judge, every surface. `--worktree` and `--range <base>..<head>` run the same
23
23
  judgment over the working tree or a ref range as a diagnostic call, with no witness prompt.
24
- Context-family disciplines (`requirePrecedent`) assemble
25
- here like any other, but with no session to read they become skip registrations: when one
24
+ Declarations that read the session (`precedent` and the other history mechanisms) assemble
25
+ here like any other, but with no session to read their `supply: pass` takes over: when one
26
26
  matches a staged change it records a `skipped` event carrying its id and that change, and the
27
27
  commit proceeds. Judging them would block every matching commit with no legitimate pass path;
28
28
  filtering them out would hide that a gate stood down. It is the same disposition the session
@@ -34,7 +34,7 @@ one-way, through the core alone.
34
34
  it carries a `why`, and for each skip the compiler's reason — the one that otherwise reaches
35
35
  stderr only when the cause is a config fault. No judge thunk is called and no telemetry row is
36
36
  written. A config that cannot be loaded fails at exit `2` with stdout at zero bytes.
37
- - **`pdks init claude-code`** — the session-surface installer. It proves `polydeukes` resolves
37
+ - **`pdks init claude-code`** — the Claude Code session-surface installer. It proves `polydeukes` resolves
38
38
  from the target project before writing anything, then creates what every distribution path
39
39
  shares (the data config and its `.polydeukes/` ignore line) and what this path owns (a delegator
40
40
  hook, its `.claude/settings.json` registration merged into whatever that file already carries,
@@ -45,6 +45,14 @@ one-way, through the core alone.
45
45
  hook it just registered is itself protected. Two coexisting config spellings, an unparseable
46
46
  settings file, and a package that cannot be resolved are all precondition failures: each leaves
47
47
  zero files rather than a half-wired tree.
48
+ - **`pdks init grok`** — the Grok session-surface installer. Same preflight and shared scaffold;
49
+ this path owns `.grok/hooks/` (JSON registration with timeout 60, and a delegator file only when
50
+ no Claude delegator is on disk). If the Claude hook file already exists, the JSON command points
51
+ at it so two judges are not spawned. A later run of either installer retargets an
52
+ installer-generated grok-mjs command the same way, and the JSON matcher follows the
53
+ `.claude/settings.json` entry for that command — Grok collapses two registrations only when
54
+ command and matcher both match. An already-open Grok session keeps the hook snapshot from
55
+ start. The witness valve does not open on Grok.
48
56
  - **`pdks docs [topic]`** — the offline documentation reader. The English guides and the reference
49
57
  layer are copied into `dist/docs` at build time, so a consumer's AI partner reads the
50
58
  documentation that shipped with the code doing the judging instead of whichever release a search
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Post-hoc state comparison — the baseline comparator.
3
+ *
4
+ * The session surface judges declared calls, so a write nobody declared leaves no row at
5
+ * all. This module answers the same question from the other end: summarize the protected
6
+ * entries' on-disk state, and after the fact name the entries whose state moved with no
7
+ * judgment row explaining it. It records rather than blocks — every failure shape here
8
+ * resolves to a value, never a throw.
9
+ *
10
+ * Split the way the telemetry module is: {@link findUnattributed} is pure and
11
+ * {@link snapshotBaseline} / {@link writeBaseline} / {@link readBaseline} are the I/O
12
+ * points.
13
+ */
14
+ import type { TelemetryRecord } from '@polydeukes/core';
15
+ /** One hash per protected entry, keyed by the entry exactly as configured. */
16
+ export type BaselineSnapshot = Record<string, string>;
17
+ /**
18
+ * The baseline file's contents: the entry hashes plus the instant the attribution window
19
+ * opens at. Named for the file rather than the concept — `Baseline` already belongs to the
20
+ * delta family, where it means a file's prior CONTENT, not a protection snapshot.
21
+ *
22
+ * The two fields travel together because they are read together — pairing one comparison's
23
+ * hashes with another's cut would silently mis-attribute, undetectably.
24
+ */
25
+ export type StoredBaseline = {
26
+ entries: BaselineSnapshot;
27
+ cutAt?: string;
28
+ };
29
+ /**
30
+ * Summarize each entry's on-disk state as one sha256.
31
+ *
32
+ * `entries` are repo-relative paths of either shape — a file or a directory. Every entry
33
+ * keeps a key whatever its state, so the diff always sees it; a missing entry simply hashes
34
+ * to the empty set.
35
+ */
36
+ export declare function snapshotBaseline(spec: {
37
+ rootDir: string;
38
+ entries: string[];
39
+ }): BaselineSnapshot;
40
+ /**
41
+ * The entries whose hash changed and that no window row explains (pure).
42
+ *
43
+ * Attribution is asked per entry, never per window: "the window holds an attributing row"
44
+ * and "THIS entry's change has one" are different claims, and answering the first is the
45
+ * fail-open shape this repository already shipped once.
46
+ *
47
+ * The window is cut by `cutAt` — rows at or after that instant, which are the ones written
48
+ * since the previous comparison. Cutting by time rather than by a record count keeps the
49
+ * bound meaningful when the log is trimmed or rotated; a positional index would point past
50
+ * the end and empty the window for every later call. An absent cut admits every row: over-
51
+ * attributing costs one missed alarm in a session that is already anomalous, while under-
52
+ * attributing floods an ordinary one and teaches the reader to ignore the alarm entirely.
53
+ *
54
+ * Only entries the previous baseline already watched are compared. An entry the config just
55
+ * added has no prior hash, and reporting it would make editing one's own policy look
56
+ * identical to a tamper; an entry the config dropped is simply no longer watched.
57
+ */
58
+ export declare function findUnattributed(spec: {
59
+ previous: BaselineSnapshot;
60
+ current: BaselineSnapshot;
61
+ records: TelemetryRecord[];
62
+ cutAt?: string;
63
+ }): string[];
64
+ /**
65
+ * Write the baseline file.
66
+ *
67
+ * `cutAt` is the instant this comparison ran, so the next one attributes from rows written
68
+ * at or after it. Written at call END, so the rows explaining what this call itself changed
69
+ * fall before the cut — the snapshot beside them already absorbed those changes.
70
+ */
71
+ export declare function writeBaseline(path: string, snapshot: BaselineSnapshot, cutAt?: string): void;
72
+ /**
73
+ * Read the baseline — entries and window cut in ONE parse — or `null`.
74
+ *
75
+ * One parse rather than two: reading the hashes and the cut separately lets a concurrent
76
+ * write land between them, pairing one comparison's state with another's window bound. That
77
+ * mismatch changes which entries are attributed and leaves no trace anywhere.
78
+ *
79
+ * Absence and corruption are the same signal — re-establish and record — so neither throws.
80
+ * Observation is fail-open: the worst outcome is a missing datum, never a blocked call.
81
+ */
82
+ export declare function readBaseline(path: string): StoredBaseline | null;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Post-hoc state comparison — the baseline comparator.
3
+ *
4
+ * The session surface judges declared calls, so a write nobody declared leaves no row at
5
+ * all. This module answers the same question from the other end: summarize the protected
6
+ * entries' on-disk state, and after the fact name the entries whose state moved with no
7
+ * judgment row explaining it. It records rather than blocks — every failure shape here
8
+ * resolves to a value, never a throw.
9
+ *
10
+ * Split the way the telemetry module is: {@link findUnattributed} is pure and
11
+ * {@link snapshotBaseline} / {@link writeBaseline} / {@link readBaseline} are the I/O
12
+ * points.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ /**
18
+ * The two verdict words that explain a change — the ones that mean a mutation of a
19
+ * protected entry was judged and let through anyway.
20
+ *
21
+ * `witnessed` is a break a human opened in person; `advised` is one an advise-level surface
22
+ * recorded without stopping. Both name a write the session knew about, so the change that
23
+ * follows is accounted for.
24
+ *
25
+ * The other four do not attribute, each for its own reason. `passed` means the call was
26
+ * judged and did NOT break the covenant — on a protected entry that is a mention, not a
27
+ * mutation, so it explains no later change; admitting it is what let a read-only call
28
+ * absolve a tamper. `blocked` stops the call, so what follows is not its doing, and
29
+ * admitting it would make provoking a block a licence for every later write to that entry
30
+ * (the residue a blocked call really left is present when that same call compares, reported
31
+ * there once, and folded into its own re-establishment). `skipped` is a recorded absence of
32
+ * judgment, which explains nothing. `unattributed` must never silence its own successor.
33
+ */
34
+ const ATTRIBUTING_EVENTS = ['witnessed', 'advised'];
35
+ /**
36
+ * Fold one file's path and content into `hash`.
37
+ *
38
+ * The path participates, so a rename with byte-identical content still moves the entry's
39
+ * hash. An unreadable file contributes a fixed absence marker rather than aborting the
40
+ * walk — absence is a state, and a file that vanished mid-walk is exactly the state the
41
+ * comparison exists to see. The marker leads with an escaped NUL so no readable file's
42
+ * contents can collide with it; written as an escape rather than a literal byte, which
43
+ * would make this source a binary file to git and hide it from every diff.
44
+ */
45
+ function foldFile(hash, absolutePath, key) {
46
+ hash.update(key);
47
+ try {
48
+ hash.update(readFileSync(absolutePath));
49
+ }
50
+ catch {
51
+ hash.update('\0absent');
52
+ }
53
+ }
54
+ /**
55
+ * Fold everything reachable under `absolutePath` into `hash`, in sorted order.
56
+ *
57
+ * Sorted so an unchanged tree hashes identically twice — directory iteration order is not
58
+ * a guarantee, and a nondeterministic term would read as a change on every call. A path
59
+ * that is a file, or that does not exist at all, is not a traversal failure: six of the
60
+ * live config's thirteen entries name a file, and a vanished entry hashes to the empty set.
61
+ */
62
+ function foldEntry(hash, rootDir, relPath) {
63
+ let children;
64
+ try {
65
+ children = readdirSync(join(rootDir, relPath), { withFileTypes: true });
66
+ }
67
+ catch {
68
+ foldFile(hash, join(rootDir, relPath), relPath);
69
+ return;
70
+ }
71
+ for (const child of [...children].sort((a, b) => a.name.localeCompare(b.name))) {
72
+ foldEntry(hash, rootDir, `${relPath}/${child.name}`);
73
+ }
74
+ }
75
+ /**
76
+ * Summarize each entry's on-disk state as one sha256.
77
+ *
78
+ * `entries` are repo-relative paths of either shape — a file or a directory. Every entry
79
+ * keeps a key whatever its state, so the diff always sees it; a missing entry simply hashes
80
+ * to the empty set.
81
+ */
82
+ export function snapshotBaseline(spec) {
83
+ const snapshot = {};
84
+ for (const entry of spec.entries) {
85
+ const hash = createHash('sha256');
86
+ foldEntry(hash, spec.rootDir, entry);
87
+ snapshot[entry] = hash.digest('hex');
88
+ }
89
+ return snapshot;
90
+ }
91
+ /**
92
+ * The entries whose hash changed and that no window row explains (pure).
93
+ *
94
+ * Attribution is asked per entry, never per window: "the window holds an attributing row"
95
+ * and "THIS entry's change has one" are different claims, and answering the first is the
96
+ * fail-open shape this repository already shipped once.
97
+ *
98
+ * The window is cut by `cutAt` — rows at or after that instant, which are the ones written
99
+ * since the previous comparison. Cutting by time rather than by a record count keeps the
100
+ * bound meaningful when the log is trimmed or rotated; a positional index would point past
101
+ * the end and empty the window for every later call. An absent cut admits every row: over-
102
+ * attributing costs one missed alarm in a session that is already anomalous, while under-
103
+ * attributing floods an ordinary one and teaches the reader to ignore the alarm entirely.
104
+ *
105
+ * Only entries the previous baseline already watched are compared. An entry the config just
106
+ * added has no prior hash, and reporting it would make editing one's own policy look
107
+ * identical to a tamper; an entry the config dropped is simply no longer watched.
108
+ */
109
+ export function findUnattributed(spec) {
110
+ const cutAt = spec.cutAt;
111
+ const attributed = new Set(spec.records
112
+ .filter((record) => cutAt === undefined || record.timestamp >= cutAt)
113
+ .filter((record) => ATTRIBUTING_EVENTS.includes(record.event))
114
+ .map((record) => record.subject));
115
+ return Object.keys(spec.current).filter((entry) => entry in spec.previous &&
116
+ spec.current[entry] !== spec.previous[entry] &&
117
+ !attributed.has(entry));
118
+ }
119
+ /**
120
+ * Write the baseline file.
121
+ *
122
+ * `cutAt` is the instant this comparison ran, so the next one attributes from rows written
123
+ * at or after it. Written at call END, so the rows explaining what this call itself changed
124
+ * fall before the cut — the snapshot beside them already absorbed those changes.
125
+ */
126
+ export function writeBaseline(path, snapshot, cutAt) {
127
+ writeFileSync(path, JSON.stringify({ entries: snapshot, cutAt }));
128
+ }
129
+ /**
130
+ * Read the baseline — entries and window cut in ONE parse — or `null`.
131
+ *
132
+ * One parse rather than two: reading the hashes and the cut separately lets a concurrent
133
+ * write land between them, pairing one comparison's state with another's window bound. That
134
+ * mismatch changes which entries are attributed and leaves no trace anywhere.
135
+ *
136
+ * Absence and corruption are the same signal — re-establish and record — so neither throws.
137
+ * Observation is fail-open: the worst outcome is a missing datum, never a blocked call.
138
+ */
139
+ export function readBaseline(path) {
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ // Validate what the comparison will use: valid JSON of the wrong shape parses fine and
148
+ // then crashes the diff, which the fail-open wiring swallows — detection would go dark
149
+ // with no re-establishment and no row.
150
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
151
+ return null;
152
+ }
153
+ const { entries, cutAt } = parsed;
154
+ if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
155
+ return null;
156
+ }
157
+ if (Object.values(entries).some((value) => typeof value !== 'string')) {
158
+ return null;
159
+ }
160
+ // An unparseable cut degrades to absent, which admits the whole window — the same safe
161
+ // direction {@link findUnattributed} takes, rather than a bound nobody can trust.
162
+ return {
163
+ entries: entries,
164
+ cutAt: typeof cutAt === 'string' ? cutAt : undefined,
165
+ };
166
+ }
package/dist/bin.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * `pdks` / `polydeukes` — the umbrella bin.
4
4
  *
5
- * A thin argv shim: four subcommands, each matched by direct comparison against a finite
5
+ * A thin argv shim: each subcommand is matched by direct comparison against a finite
6
6
  * table. Anything else prints usage and exits 2 — an unknown argument must never pass
7
7
  * silently (fail-closed, the same posture as an unjudgeable payload).
8
8
  *
package/dist/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * `pdks` / `polydeukes` — the umbrella bin.
4
4
  *
5
- * A thin argv shim: four subcommands, each matched by direct comparison against a finite
5
+ * A thin argv shim: each subcommand is matched by direct comparison against a finite
6
6
  * table. Anything else prints usage and exits 2 — an unknown argument must never pass
7
7
  * silently (fail-closed, the same posture as an unjudgeable payload).
8
8
  *
@@ -88,6 +88,23 @@ if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
88
88
  process.exit(2);
89
89
  }
90
90
  }
91
+ if (args.length === 2 && args[0] === 'init' && args[1] === 'grok') {
92
+ try {
93
+ const { initGrok } = await import('./init-grok.js');
94
+ const { created, skipped } = initGrok({ projectRoot: process.cwd() });
95
+ for (const path of created) {
96
+ process.stdout.write(`created ${path}\n`);
97
+ }
98
+ for (const path of skipped) {
99
+ process.stdout.write(`skipped ${path} (already present)\n`);
100
+ }
101
+ process.exit(0);
102
+ }
103
+ catch (error) {
104
+ process.stderr.write(`pdks init grok failed: ${error instanceof Error ? error.message : String(error)}\n`);
105
+ process.exit(2);
106
+ }
107
+ }
91
108
  if (args[0] === 'docs' && args.length <= 2) {
92
109
  try {
93
110
  // Imported inside the try for the same reason `init` is: the query core and the
@@ -149,7 +166,7 @@ function parseCheckDomain(flags) {
149
166
  }
150
167
  const domain = args[0] === 'covenant' && args[1] === 'check' ? parseCheckDomain(args.slice(2)) : null;
151
168
  if (domain === null) {
152
- process.stderr.write('usage: pdks covenant check [--worktree | --range <base>..<head>] | pdks explain | pdks init claude-code | pdks docs [topic]\n');
169
+ process.stderr.write('usage: pdks covenant check [--worktree | --range <base>..<head>] | pdks explain | pdks init claude-code | pdks init grok | pdks docs [topic]\n');
153
170
  process.exit(2);
154
171
  }
155
172
  try {
@@ -25,8 +25,12 @@
25
25
  */
26
26
  import { type CanonicalTranscript } from '@polydeukes/core';
27
27
  import { type CovenantRegistration } from '@polydeukes/covenant';
28
- import { type CovenantModule } from './covenant-module.js';
29
- import { loadConfig } from './load-config.js';
28
+ import { type CovenantModule } from './covenant-module.ts';
29
+ import { loadConfig } from './load-config.ts';
30
+ /** {@link runClaudeCodeHook} result — the exit code the hook process leaves with. */
31
+ export type ClaudeCodeHookOutcome = {
32
+ exitCode: 0 | 2;
33
+ };
30
34
  /** `runClaudeCodeHook` input — the `CovenantCheckSpec` shape, session side. */
31
35
  export type ClaudeCodeHookSpec = {
32
36
  /** Repository root — config discovery and discipline glob scoping both anchor here. */
@@ -72,6 +76,4 @@ export declare function assembleSessionRegistrations(spec: SessionAssemblySpec):
72
76
  * left and its rows land ahead of this call's judgment; the re-establishment runs last, so
73
77
  * this call's own judged writes are folded in rather than alarmed on next time.
74
78
  */
75
- export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<{
76
- exitCode: 0 | 2;
77
- }>;
79
+ export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<ClaudeCodeHookOutcome>;
@@ -26,11 +26,13 @@
26
26
  import { mkdirSync, readFileSync } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
28
  import { join, resolve } from 'node:path';
29
- import { COMMAND_ARGS, evaluatePrecedent, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
30
- import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, readRecords, } from '@polydeukes/core';
31
- import { findUnattributed, readBaseline, snapshotBaseline, ttlWitness, writeBaseline, } from '@polydeukes/covenant';
29
+ import { COMMAND_ARGS, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, sessionChannelReader, sessionSourceReader, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
30
+ import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, isPlainObject, normalizeProtectedPaths, readRecords, } from '@polydeukes/core';
31
+ import { ttlWitness } from '@polydeukes/covenant';
32
+ import { findUnattributed, readBaseline, snapshotBaseline, writeBaseline } from './baseline.js';
32
33
  import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
33
34
  import { loadConfig } from './load-config.js';
35
+ import { sessionPreStateReader } from './pre-state-reader.js';
34
36
  /** The label every post-hoc state comparison row carries. */
35
37
  const BASELINE_LABEL = 'baseline';
36
38
  /**
@@ -109,7 +111,7 @@ function updateBaseline(spec) {
109
111
  function comparisonSpec(spec) {
110
112
  let config;
111
113
  try {
112
- config = loadConfig(spec.repoRoot).config;
114
+ config = loadConfig({ rootDir: spec.repoRoot }).config;
113
115
  }
114
116
  catch {
115
117
  return undefined;
@@ -129,9 +131,9 @@ function comparisonSpec(spec) {
129
131
  */
130
132
  export function assembleSessionRegistrations(spec) {
131
133
  const { config, rootDir, covenant, transcriptPath, transcript, witness } = spec;
132
- // The live transcript is the evidence channel the context family reads AND the one the
133
- // witness reads, so erasing or forging it disables every context discipline while opening
134
- // or shutting the human valve on the same file. It must NOT join this list: it lives deep
134
+ // The live transcript is the session a history declaration reads AND the one the witness
135
+ // reads, so erasing or forging it disables every history discipline while opening or
136
+ // shutting the human valve on the same file. It must NOT join this list: it lives deep
135
137
  // under HOME, and a path entry makes every ancestor protected — which measured as an
136
138
  // over-block refusing `cd /home/<user>`, `echo $HOME`, and every edit whose content
137
139
  // carried a bare `~`. The dedicated `transcript-mod` registration below covers that one
@@ -156,7 +158,7 @@ export function assembleSessionRegistrations(spec) {
156
158
  }),
157
159
  // Routing is the matches predicate, never path mention, so the home directory cannot
158
160
  // become a protected ancestor. No transcript in the payload means nothing to protect —
159
- // the valve and the context family already forfeited on the same absence.
161
+ // the valve and every history declaration already forfeited on the same absence.
160
162
  ...(transcriptPath === undefined
161
163
  ? []
162
164
  : [
@@ -179,17 +181,56 @@ export function assembleSessionRegistrations(spec) {
179
181
  rootDir,
180
182
  shellTools: SHELL_TOOLS,
181
183
  commandArgs: COMMAND_ARGS,
184
+ readPreState: sessionPreStateReader,
185
+ // One PreToolUse call is the whole observation, so the derived change set is a
186
+ // singleton and a change-set declaration cannot be judged here — it records `skipped`,
187
+ // the shape the commit surface gives a history declaration.
188
+ observesChangeSet: false,
182
189
  witness,
183
- // Context-family evidence is evaluated here, at assembly: a spawned body cannot hold
184
- // a transcript, and passing a path would leak JSONL knowledge into covenant. The
185
- // adapter brings the evaluator for its own `subagent`/`tool` vocabulary; core owns
186
- // `command`, which the compiler judges directly.
190
+ // The session itself, injected rather than its path: a declaration reading a
191
+ // `transcript` binding sees it flattened, and passing a path would leak JSONL
192
+ // knowledge into covenant.
187
193
  transcript,
188
- evaluatePrecedent,
189
194
  }),
190
195
  ];
191
196
  return registrations;
192
197
  }
198
+ /**
199
+ * This runtime's mutating+shell roster, rewritten onto the Claude vocabulary the adapter
200
+ * already judges. Claude names are not keys, so an existing Write/Edit/Bash envelope
201
+ * passes through. A name outside the table is left alone — that is a declared limit,
202
+ * recorded as the adapter's funnel pass, never a parse fault. The map lives here, not in
203
+ * the adapter, so the adapter stays Claude-vocabulary-only.
204
+ */
205
+ const GROK_TOOL_NAME_MAP = {
206
+ write: 'Write',
207
+ search_replace: 'Edit',
208
+ run_terminal_command: 'Bash',
209
+ };
210
+ /**
211
+ * Rewrite Grok tool names in a raw PreToolUse payload. Invalid JSON is left as the original
212
+ * string so the existing fail-closed path still runs — this function must not throw.
213
+ */
214
+ function rewriteGrokToolNames(rawPayload) {
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(rawPayload);
218
+ }
219
+ catch {
220
+ return rawPayload;
221
+ }
222
+ if (!isPlainObject(parsed))
223
+ return rawPayload;
224
+ for (const key of ['tool_name', 'toolName']) {
225
+ const value = parsed[key];
226
+ if (typeof value !== 'string')
227
+ continue;
228
+ const mapped = GROK_TOOL_NAME_MAP[value];
229
+ if (mapped !== undefined)
230
+ parsed[key] = mapped;
231
+ }
232
+ return JSON.stringify(parsed);
233
+ }
193
234
  /**
194
235
  * Judge one declared tool call before it runs. Async because the dispatcher spawns covenant
195
236
  * bodies — a synchronous runner would mean reimplementing the judge, which the
@@ -212,20 +253,20 @@ async function judgeHookCall(spec) {
212
253
  spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
213
254
  // Discovery + parse + validation are the loader's job; a throw here (absent, ambiguous,
214
255
  // unparseable, or invalid config) falls into the fail-closed catch.
215
- const { config } = loadConfig(spec.repoRoot);
256
+ const { config } = loadConfig({ rootDir: spec.repoRoot });
216
257
  telemetryPath =
217
258
  spec.telemetryPath ?? envTelemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
218
259
  // Settled for the rest of the happy path. The `let` above exists so the catch can still
219
260
  // record when a failure lands before this point; a closure cannot narrow it, so the
220
261
  // dispatch seam below takes this const instead.
221
262
  const logPath = telemetryPath;
222
- const rawPayload = spec.rawPayload ?? readFileSync(0, 'utf-8');
263
+ const rawPayload = rewriteGrokToolNames(spec.rawPayload ?? readFileSync(0, 'utf-8'));
223
264
  // The transcript path travels in the raw payload only — up-translation drops it, so the
224
265
  // adapter reads it from the string. Every failure narrows to `undefined`, which leaves
225
266
  // the dispatcher on its `noopTranscript` default: lost evidence closes the valve rather
226
267
  // than opening it.
227
- const transcriptPath = transcriptPathFromPayload(rawPayload);
228
- const transcript = transcriptPath === undefined ? undefined : transcriptFromJsonlFile(transcriptPath);
268
+ const transcriptPath = transcriptPathFromPayload({ rawPayload });
269
+ const transcript = transcriptPath === undefined ? undefined : transcriptFromJsonlFile({ path: transcriptPath });
229
270
  // One witness predicate shared by every registration: a witness is a session-wide
230
271
  // permission the human granted, not a per-covenant one. Absent `witness` config leaves
231
272
  // this undefined, and no verdict can be witnessed open at all. The predicate receives
@@ -260,6 +301,16 @@ async function judgeHookCall(spec) {
260
301
  transcript,
261
302
  witness,
262
303
  });
304
+ // The world axis: files read from disk under the repository root, channels read beside
305
+ // the session's transcript. The disk is the pre-edit state on this surface; the rule
306
+ // that the judged change's own `post` overrides it belongs to the judge, so the root
307
+ // supplies what it read and nothing more. No `changes` list either — one PreToolUse call
308
+ // is the whole observation, and the judge derives that set from the input.
309
+ const { files, channels } = covenant.supplySources({
310
+ plan: covenant.planSources({ registrations }),
311
+ read: sessionSourceReader({ repoRoot: spec.repoRoot }),
312
+ readChannel: sessionChannelReader({ transcriptPath }),
313
+ });
263
314
  return await runAdapterPath({
264
315
  rawPayload,
265
316
  telemetryPath: logPath,
@@ -268,6 +319,7 @@ async function judgeHookCall(spec) {
268
319
  registrations,
269
320
  telemetryPath: logPath,
270
321
  transcript,
322
+ world: { files, channels },
271
323
  }),
272
324
  });
273
325
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `polydeukes/claude-code` — the session surface's entry point.
3
+ *
4
+ * The delegator a consumer project's PreToolUse hook imports: one verb and its spec type.
5
+ */
6
+ export { type ClaudeCodeHookOutcome, type ClaudeCodeHookSpec, runClaudeCodeHook, } from './claude-code-hook.ts';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `polydeukes/claude-code` — the session surface's entry point.
3
+ *
4
+ * The delegator a consumer project's PreToolUse hook imports: one verb and its spec type.
5
+ */
6
+ export { runClaudeCodeHook, } from './claude-code-hook.js';
@@ -10,26 +10,21 @@
10
10
  * fail-closed: a missing config, an unbuilt body, or a collector failure exits 2 with one
11
11
  * blocked record. An empty domain is an explicit pass with no records.
12
12
  */
13
+ import { type Observation } from '@polydeukes/adapter-git';
13
14
  import type { CovenantRegistration } from '@polydeukes/covenant';
14
- import { type CovenantModule } from './covenant-module.js';
15
- import { loadConfig } from './load-config.js';
15
+ import { type CovenantModule } from './covenant-module.ts';
16
+ import { loadConfig } from './load-config.ts';
16
17
  /**
17
18
  * Which observation of the commit surface a run judges. Only the collector differs between
18
19
  * them; the IR, the assembly, and the dispatcher are one path.
19
20
  *
20
- * `range` names its two refs. `ancestry: 'merge-base'` selects the `A...B` reading, whose
21
- * base is the two refs' common ancestor rather than `A` itself; the adapter that owns the
22
- * range grammar resolves it.
21
+ * The adapter that owns the git grammar owns the type: its supply body reads a path the way
22
+ * each observation sees the tree, and this root names the same fact for its callers.
23
23
  */
24
- export type CheckDomain = {
25
- kind: 'staged';
26
- } | {
27
- kind: 'worktree';
28
- } | {
29
- kind: 'range';
30
- base: string;
31
- head: string;
32
- ancestry?: 'merge-base';
24
+ export type CheckDomain = Observation;
25
+ /** {@link runCovenantCheck} result — the exit code the check process leaves with. */
26
+ export type CovenantCheckOutcome = {
27
+ exitCode: 0 | 2;
33
28
  };
34
29
  /** `runCovenantCheck` input. */
35
30
  export type CovenantCheckSpec = {
@@ -74,6 +69,4 @@ export declare function assembleCommitRegistrations(spec: CommitAssemblySpec): C
74
69
  * diff by default, the working tree or a ref range on request. Async because the dispatcher
75
70
  * spawns covenant bodies. An empty domain is an explicit pass: nothing to judge, no records.
76
71
  */
77
- export declare function runCovenantCheck(spec: CovenantCheckSpec): Promise<{
78
- exitCode: 0 | 2;
79
- }>;
72
+ export declare function runCovenantCheck(spec: CovenantCheckSpec): Promise<CovenantCheckOutcome>;