polydeukes 0.4.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 +11 -4
- package/README.md +21 -5
- package/dist/baseline.d.ts +82 -0
- package/dist/baseline.js +166 -0
- package/dist/bin.d.ts +5 -6
- package/dist/bin.js +92 -38
- package/dist/claude-code-hook.d.ts +40 -17
- package/dist/claude-code-hook.js +187 -175
- package/dist/claude-code.d.ts +6 -0
- package/dist/claude-code.js +6 -0
- package/dist/covenant-check.d.ts +50 -36
- package/dist/covenant-check.js +167 -193
- package/dist/covenant-module.d.ts +25 -0
- package/dist/covenant-module.js +42 -0
- package/dist/docs/configuration.md +17 -9
- package/dist/docs/installation.md +42 -12
- package/dist/docs/reference/adapter-claude-code.md +6 -4
- package/dist/docs/reference/adapter-git.md +23 -10
- package/dist/docs/reference/configuration.md +265 -103
- package/dist/docs/reference/core.md +15 -7
- package/dist/docs/reference/covenant.md +32 -24
- package/dist/docs/reference/polydeukes.md +132 -32
- package/dist/docs/troubleshooting.md +37 -8
- package/dist/docs-query.d.ts +10 -10
- package/dist/docs-query.js +12 -12
- package/dist/explain.d.ts +25 -0
- package/dist/explain.js +153 -0
- package/dist/index.d.ts +11 -16
- package/dist/index.js +10 -15
- package/dist/init-claude-code.d.ts +31 -18
- package/dist/init-claude-code.js +254 -40
- package/dist/init-grok.d.ts +51 -0
- package/dist/init-grok.js +242 -0
- package/dist/load-config.d.ts +17 -15
- package/dist/load-config.js +13 -12
- package/dist/pre-state-reader.d.ts +22 -0
- package/dist/pre-state-reader.js +32 -0
- package/dist/scaffold-project.d.ts +23 -14
- package/dist/scaffold-project.js +97 -32
- package/dist/schema/polydeukes.schema.json +54 -81
- package/package.json +7 -7
package/README.ko.md
CHANGED
|
@@ -11,16 +11,17 @@
|
|
|
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). 조용한 기본값은 금지이고, 발견된 파일은 자기 자신을 보호
|
|
18
18
|
표면에 편입시킵니다.
|
|
19
19
|
- **`pdks covenant check`.** `pdks` bin의 첫 실물 서브커맨드입니다(`polydeukes`는 별칭).
|
|
20
20
|
pre-commit 판정 러너로, 스테이징 영역의 변경을 `@polydeukes/adapter-git`이 수집해
|
|
21
|
-
약속(covenant) 입력 IR로 번역하고, 세션 훅이
|
|
22
|
-
판정기는 하나, 표면은 여럿입니다.
|
|
23
|
-
|
|
21
|
+
약속(covenant) 입력 IR로 번역하고, 세션 훅이 부르는 바로 그 in-process 판정기로 보냅니다.
|
|
22
|
+
판정기는 하나, 표면은 여럿입니다. `--worktree`와 `--range <base>..<head>`는 같은 판정을
|
|
23
|
+
작업 트리나 ref 범위에 진단 호출로 돌리며, 증인 프롬프트는 뜨지 않습니다. 세션을 읽는 선언(`precedent`와 다른 이력 기전)도 다른 규율과
|
|
24
|
+
똑같이 조립되지만, 읽을 세션이 없으므로 자기 `supply: pass`가 처분합니다. 스테이징 변경과 매치하면 자기
|
|
24
25
|
id와 그 변경을 담은 `skipped` 이벤트를 남기고 커밋은 진행됩니다. 여기서 판정하면 걸리는
|
|
25
26
|
커밋마다 정당하게 통과할 길 없이 막히고, 아예 걸러내면 게이트가 물러섰다는 사실이 가려집니다.
|
|
26
27
|
세션 표면이 읽을 transcript를 갖지 못했을 때와 같은 처분입니다. 빈 스테이징은 명시적
|
|
@@ -35,6 +36,12 @@
|
|
|
35
36
|
방금 등록한 훅 자신이 보호 대상이기 때문입니다. config 철자 둘이 공존하는 트리, 파싱되지 않는
|
|
36
37
|
settings 파일, 해소되지 않는 패키지는 전부 선행 조건 실패입니다. 반쯤 배선된 트리를 남기는
|
|
37
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에서 열리지 않습니다.
|
|
38
45
|
- **`pdks docs [topic]`.** 오프라인 문서 열람기입니다. 영어 가이드와 레퍼런스 층이 빌드 시점에
|
|
39
46
|
`dist/docs`로 복사되므로, 소비자의 AI 파트너는 검색 엔진이 색인한 아무 판본이 아니라 판정을
|
|
40
47
|
수행하는 코드와 함께 실린 문서를 읽습니다. 인자 없이 부르면 토픽 다섯을 열거하는데, 에이전트가
|
package/README.md
CHANGED
|
@@ -11,22 +11,30 @@ 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
|
|
18
18
|
itself to its own protection surface.
|
|
19
19
|
- **`pdks covenant check`** — the first real subcommand of the `pdks` bin (`polydeukes` is an
|
|
20
20
|
alias). A pre-commit judgment runner: staged changes are collected by `@polydeukes/adapter-git`,
|
|
21
|
-
translated into the covenant input IR, and dispatched through the very
|
|
22
|
-
hook
|
|
23
|
-
|
|
21
|
+
translated into the covenant input IR, and dispatched through the very in-process judges the
|
|
22
|
+
session hook calls — one judge, every surface. `--worktree` and `--range <base>..<head>` run the same
|
|
23
|
+
judgment over the working tree or a ref range as a diagnostic call, with no witness prompt.
|
|
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
|
|
24
26
|
matches a staged change it records a `skipped` event carrying its id and that change, and the
|
|
25
27
|
commit proceeds. Judging them would block every matching commit with no legitimate pass path;
|
|
26
28
|
filtering them out would hide that a gate stood down. It is the same disposition the session
|
|
27
29
|
surface uses when it has no transcript. An empty staging area is an explicit pass; a missing or
|
|
28
30
|
invalid config fails closed.
|
|
29
|
-
- **`pdks
|
|
31
|
+
- **`pdks explain`** — the assembly reader. It assembles both surfaces' registration sets through
|
|
32
|
+
the same functions the two runners call and prints them without judging: every registration
|
|
33
|
+
with its kind (`meta` / `judge` / `skip` / `excluded`), the routing scope of each entry, whether
|
|
34
|
+
it carries a `why`, and for each skip the compiler's reason — the one that otherwise reaches
|
|
35
|
+
stderr only when the cause is a config fault. No judge thunk is called and no telemetry row is
|
|
36
|
+
written. A config that cannot be loaded fails at exit `2` with stdout at zero bytes.
|
|
37
|
+
- **`pdks init claude-code`** — the Claude Code session-surface installer. It proves `polydeukes` resolves
|
|
30
38
|
from the target project before writing anything, then creates what every distribution path
|
|
31
39
|
shares (the data config and its `.polydeukes/` ignore line) and what this path owns (a delegator
|
|
32
40
|
hook, its `.claude/settings.json` registration merged into whatever that file already carries,
|
|
@@ -37,6 +45,14 @@ one-way, through the core alone.
|
|
|
37
45
|
hook it just registered is itself protected. Two coexisting config spellings, an unparseable
|
|
38
46
|
settings file, and a package that cannot be resolved are all precondition failures: each leaves
|
|
39
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.
|
|
40
56
|
- **`pdks docs [topic]`** — the offline documentation reader. The English guides and the reference
|
|
41
57
|
layer are copied into `dist/docs` at build time, so a consumer's AI partner reads the
|
|
42
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;
|
package/dist/baseline.js
ADDED
|
@@ -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
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `pdks` / `polydeukes` — the umbrella bin
|
|
3
|
+
* `pdks` / `polydeukes` — the umbrella bin.
|
|
4
4
|
*
|
|
5
|
-
* A thin argv shim
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (fail-closed, the same posture as an unjudgeable payload).
|
|
5
|
+
* A thin argv shim: each subcommand is matched by direct comparison against a finite
|
|
6
|
+
* table. Anything else prints usage and exits 2 — an unknown argument must never pass
|
|
7
|
+
* silently (fail-closed, the same posture as an unjudgeable payload).
|
|
9
8
|
*
|
|
10
9
|
* The real TTY is wired HERE, not in the library: the runner receives an injectable
|
|
11
10
|
* seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
|
|
12
11
|
* CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
|
|
13
|
-
* the valve is structurally unreachable
|
|
12
|
+
* the witness valve is structurally unreachable, so only a human at a terminal can arm it.
|
|
14
13
|
*/
|
|
15
14
|
export {};
|
package/dist/bin.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `pdks` / `polydeukes` — the umbrella bin
|
|
3
|
+
* `pdks` / `polydeukes` — the umbrella bin.
|
|
4
4
|
*
|
|
5
|
-
* A thin argv shim
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (fail-closed, the same posture as an unjudgeable payload).
|
|
5
|
+
* A thin argv shim: each subcommand is matched by direct comparison against a finite
|
|
6
|
+
* table. Anything else prints usage and exits 2 — an unknown argument must never pass
|
|
7
|
+
* silently (fail-closed, the same posture as an unjudgeable payload).
|
|
9
8
|
*
|
|
10
9
|
* The real TTY is wired HERE, not in the library: the runner receives an injectable
|
|
11
10
|
* seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
|
|
12
11
|
* CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
|
|
13
|
-
* the valve is structurally unreachable
|
|
12
|
+
* the witness valve is structurally unreachable, so only a human at a terminal can arm it.
|
|
14
13
|
*/
|
|
15
14
|
import { closeSync, openSync, readSync, writeSync } from 'node:fs';
|
|
16
15
|
import { dirname, join } from 'node:path';
|
|
@@ -46,20 +45,32 @@ function openTtyPrompt() {
|
|
|
46
45
|
closeSync(fd);
|
|
47
46
|
}
|
|
48
47
|
catch {
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// seam (PR #41 review). The valve caches its verdict, so this is defensive.
|
|
48
|
+
// An EBADF thrown from this finally would override the `return null` above and
|
|
49
|
+
// escape the seam.
|
|
52
50
|
}
|
|
53
51
|
}
|
|
54
52
|
};
|
|
55
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Write `text` to stdout and end the process — exit 0 once the write drains, exit 2 when
|
|
56
|
+
* the reader went away. A piped write is asynchronous, so the exit waits for the flush; a
|
|
57
|
+
* reader that closes mid-write makes the stream emit `error` outside any try frame, and
|
|
58
|
+
* this handler is what keeps that off node's default exit 1 with a stack trace.
|
|
59
|
+
*/
|
|
60
|
+
async function emitAndExit(text) {
|
|
61
|
+
process.stdout.on('error', () => process.exit(2));
|
|
62
|
+
await new Promise((settle) => {
|
|
63
|
+
process.stdout.write(text, () => settle());
|
|
64
|
+
});
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
56
67
|
const args = process.argv.slice(2);
|
|
57
68
|
if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
|
|
58
69
|
try {
|
|
59
70
|
// Imported inside the try, not above it: ESM imports are eager, so the installer stays
|
|
60
|
-
// off `covenant check`'s load path
|
|
61
|
-
//
|
|
62
|
-
//
|
|
71
|
+
// off `covenant check`'s load path, which a pre-commit hook spawns on every commit. A
|
|
72
|
+
// rejected import outside the try would reach node's unhandled-rejection exit 1, the
|
|
73
|
+
// exact crash this bin refuses to make.
|
|
63
74
|
const { initClaudeCode } = await import('./init-claude-code.js');
|
|
64
75
|
const { created, skipped } = initClaudeCode({ projectRoot: process.cwd() });
|
|
65
76
|
for (const path of created) {
|
|
@@ -71,49 +82,91 @@ if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
|
|
|
71
82
|
process.exit(0);
|
|
72
83
|
}
|
|
73
84
|
catch (error) {
|
|
74
|
-
// A precondition failure leaves zero files
|
|
75
|
-
//
|
|
85
|
+
// A precondition failure leaves zero files; the message names what the user has to do
|
|
86
|
+
// before running this again.
|
|
76
87
|
process.stderr.write(`pdks init claude-code failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
77
88
|
process.exit(2);
|
|
78
89
|
}
|
|
79
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
|
+
}
|
|
80
108
|
if (args[0] === 'docs' && args.length <= 2) {
|
|
81
109
|
try {
|
|
82
110
|
// Imported inside the try for the same reason `init` is: the query core and the
|
|
83
|
-
// markdown behind it have no business on `covenant check`'s load path
|
|
84
|
-
// spawns on every commit.
|
|
111
|
+
// markdown behind it have no business on `covenant check`'s load path.
|
|
85
112
|
const { queryDocs } = await import('./docs-query.js');
|
|
86
113
|
// The bundle ships beside this file, so the docs root comes from the module's own
|
|
87
114
|
// location — never from the working directory, which is whatever shell invoked us.
|
|
88
115
|
const docsRoot = join(dirname(fileURLToPath(import.meta.url)), 'docs');
|
|
89
116
|
const { text } = queryDocs({ docsRoot, topic: args[1] });
|
|
90
|
-
|
|
91
|
-
// capture) makes the stream emit `error` — an EventEmitter event, so it fires outside
|
|
92
|
-
// the frame this try guards and would reach node's default handler: exit 1 and a raw
|
|
93
|
-
// stack trace, the one disposition this bin never produces. The docs answer is not a
|
|
94
|
-
// verdict, so a reader that stopped listening is not something to report; end at the
|
|
95
|
-
// same code an unanswerable query uses.
|
|
96
|
-
process.stdout.on('error', () => process.exit(2));
|
|
97
|
-
// stdout is a pipe whenever this is captured or redirected, and a piped write is
|
|
98
|
-
// asynchronous — exiting on the next line would discard whatever is still buffered.
|
|
99
|
-
// The whole answer IS the deliverable here (a truncated document is one an agent
|
|
100
|
-
// quotes onward as if complete), so the exit waits for the flush. Awaiting rather
|
|
101
|
-
// than exiting from the callback also keeps this branch from falling through into
|
|
102
|
-
// the covenant runner below while the write drains.
|
|
103
|
-
await new Promise((settle) => {
|
|
104
|
-
process.stdout.write(text, () => settle());
|
|
105
|
-
});
|
|
106
|
-
process.exit(0);
|
|
117
|
+
await emitAndExit(text);
|
|
107
118
|
}
|
|
108
119
|
catch (error) {
|
|
109
|
-
// stdout stays at zero bytes on this path
|
|
110
|
-
//
|
|
120
|
+
// stdout stays at zero bytes on this path: what cannot be answered is never answered
|
|
121
|
+
// halfway.
|
|
111
122
|
process.stderr.write(`pdks docs: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
112
123
|
process.exit(2);
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
|
-
if (args.length
|
|
116
|
-
|
|
126
|
+
if (args.length === 1 && args[0] === 'explain') {
|
|
127
|
+
try {
|
|
128
|
+
// Imported inside the try for the same reason `docs` is: the renderer pulls in both
|
|
129
|
+
// composition roots, and neither belongs on `covenant check`'s load path.
|
|
130
|
+
const { explain } = await import('./explain.js');
|
|
131
|
+
const { text } = await explain({ repoRoot: process.cwd() });
|
|
132
|
+
await emitAndExit(text);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
// stdout stays at zero bytes on this path: what cannot be answered is never answered
|
|
136
|
+
// halfway.
|
|
137
|
+
process.stderr.write(`pdks explain: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Read the `covenant check` flags as a domain, or null when the argv is not one of the
|
|
143
|
+
* three recognized forms: no flags is the staged diff, `--worktree` is the working tree,
|
|
144
|
+
* and `--range <base>..<head>` (or `...` for the merge-base reading) is a ref range.
|
|
145
|
+
*/
|
|
146
|
+
function parseCheckDomain(flags) {
|
|
147
|
+
if (flags.length === 0)
|
|
148
|
+
return { kind: 'staged' };
|
|
149
|
+
if (flags.length === 1 && flags[0] === '--worktree')
|
|
150
|
+
return { kind: 'worktree' };
|
|
151
|
+
if (flags.length !== 2 || flags[0] !== '--range')
|
|
152
|
+
return null;
|
|
153
|
+
const range = flags[1];
|
|
154
|
+
if (range.startsWith('--'))
|
|
155
|
+
return null;
|
|
156
|
+
const mergeBase = range.includes('...');
|
|
157
|
+
const separator = mergeBase ? '...' : '..';
|
|
158
|
+
const at = range.indexOf(separator);
|
|
159
|
+
if (at === -1)
|
|
160
|
+
return null;
|
|
161
|
+
const base = range.slice(0, at);
|
|
162
|
+
const head = range.slice(at + separator.length);
|
|
163
|
+
if (base === '' || head === '')
|
|
164
|
+
return null;
|
|
165
|
+
return { kind: 'range', base, head, ...(mergeBase && { ancestry: 'merge-base' }) };
|
|
166
|
+
}
|
|
167
|
+
const domain = args[0] === 'covenant' && args[1] === 'check' ? parseCheckDomain(args.slice(2)) : null;
|
|
168
|
+
if (domain === null) {
|
|
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');
|
|
117
170
|
process.exit(2);
|
|
118
171
|
}
|
|
119
172
|
try {
|
|
@@ -127,12 +180,13 @@ try {
|
|
|
127
180
|
const { exitCode } = await runCovenantCheck({
|
|
128
181
|
repoRoot: process.cwd(),
|
|
129
182
|
ttyPrompt: openTtyPrompt(),
|
|
183
|
+
domain,
|
|
130
184
|
});
|
|
131
185
|
process.exit(exitCode);
|
|
132
186
|
}
|
|
133
187
|
catch (error) {
|
|
134
188
|
// Any failure the runner did not already translate is unjudgeable — block, never
|
|
135
|
-
// crash into node's exit 1
|
|
189
|
+
// crash into node's exit 1.
|
|
136
190
|
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
137
191
|
process.exit(2);
|
|
138
192
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `runClaudeCodeHook` — the assembled session-surface judgment runner
|
|
2
|
+
* `runClaudeCodeHook` — the assembled session-surface judgment runner.
|
|
3
3
|
*
|
|
4
4
|
* The session counterpart of {@link runCovenantCheck}, and the one place where the Claude
|
|
5
5
|
* Code adapter (tool vocabulary, up-translation) and the covenant package (dispatcher +
|
|
@@ -8,17 +8,14 @@
|
|
|
8
8
|
* a delegator that calls this function. That is what makes the session surface installable:
|
|
9
9
|
* a consumer registers a hook that resolves this package instead of copying assembly.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* root data config through {@link loadConfig} (CONFIG-03), which also attaches the config
|
|
15
|
-
* file to its own surface.
|
|
11
|
+
* The protection-policy data (protectedPaths / disciplines / witness) is read from the root
|
|
12
|
+
* data config through {@link loadConfig}, which also attaches the config file to its own
|
|
13
|
+
* surface.
|
|
16
14
|
*
|
|
17
|
-
* The valve is the TTL witness
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* utterance carries the transcript marking `findUserMessages()` admits.
|
|
15
|
+
* The valve is the TTL witness, judged over the JSONL transcript provider. The judge body
|
|
16
|
+
* always spawns, and only an outcome that translated to blocked consults the witness —
|
|
17
|
+
* `witnessed` rows are would-block only. Its defence is provenance rather than secrecy: only
|
|
18
|
+
* a real human utterance carries the transcript marking `findUserMessages()` admits.
|
|
22
19
|
*
|
|
23
20
|
* fail-closed: ANY failure — an unbuilt judge body, an unreadable stdin, a missing or
|
|
24
21
|
* invalid config file — resolves to `{ exitCode: 2 }` with one `blocked` record under the
|
|
@@ -26,7 +23,15 @@
|
|
|
26
23
|
* the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
|
|
27
24
|
* mentions no protected path, so it is never blocked).
|
|
28
25
|
*/
|
|
29
|
-
|
|
26
|
+
import { type CanonicalTranscript } from '@polydeukes/core';
|
|
27
|
+
import { type CovenantRegistration } from '@polydeukes/covenant';
|
|
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
|
+
};
|
|
34
|
+
/** `runClaudeCodeHook` input — the `CovenantCheckSpec` shape, session side. */
|
|
30
35
|
export type ClaudeCodeHookSpec = {
|
|
31
36
|
/** Repository root — config discovery and discipline glob scoping both anchor here. */
|
|
32
37
|
repoRoot: string;
|
|
@@ -37,9 +42,29 @@ export type ClaudeCodeHookSpec = {
|
|
|
37
42
|
/** Overrides the resolved covenant dist directory (tests and assembly injection). */
|
|
38
43
|
covenantDist?: string;
|
|
39
44
|
};
|
|
45
|
+
/** {@link assembleSessionRegistrations} input — what the session surface's assembly needs. */
|
|
46
|
+
export type SessionAssemblySpec = {
|
|
47
|
+
config: ReturnType<typeof loadConfig>['config'];
|
|
48
|
+
rootDir: string;
|
|
49
|
+
/**
|
|
50
|
+
* The covenant surface the registrations are built from — the module the caller loaded
|
|
51
|
+
* from the resolved dist, so what judges a call is what that dist carries, and what
|
|
52
|
+
* `explain` renders is what would judge it.
|
|
53
|
+
*/
|
|
54
|
+
covenant: CovenantModule;
|
|
55
|
+
/** The payload's transcript path. ABSENT leaves the transcript-mod registration out. */
|
|
56
|
+
transcriptPath?: string;
|
|
57
|
+
transcript?: CanonicalTranscript;
|
|
58
|
+
witness?: CovenantRegistration['witness'];
|
|
59
|
+
};
|
|
40
60
|
/**
|
|
41
|
-
* The session
|
|
42
|
-
*
|
|
61
|
+
* The session surface's registration set. One assembly, two consumers: the runner below
|
|
62
|
+
* dispatches it, `explain` renders it — so what a reader is shown is the table the judgment
|
|
63
|
+
* actually uses, never a second opinion about it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function assembleSessionRegistrations(spec: SessionAssemblySpec): CovenantRegistration[];
|
|
66
|
+
/**
|
|
67
|
+
* The session-surface entry point: the post-hoc state comparison wrapped around the judgment.
|
|
43
68
|
*
|
|
44
69
|
* The comparison sits OUTSIDE {@link judgeHookCall}'s fail-closed try on both ends. Inside
|
|
45
70
|
* it, a comparison failure would become a blocked call — the opposite of a mechanism whose
|
|
@@ -51,6 +76,4 @@ export type ClaudeCodeHookSpec = {
|
|
|
51
76
|
* left and its rows land ahead of this call's judgment; the re-establishment runs last, so
|
|
52
77
|
* this call's own judged writes are folded in rather than alarmed on next time.
|
|
53
78
|
*/
|
|
54
|
-
export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<
|
|
55
|
-
exitCode: 0 | 2;
|
|
56
|
-
}>;
|
|
79
|
+
export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<ClaudeCodeHookOutcome>;
|