polydeukes 0.3.0 → 0.5.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
@@ -18,8 +18,9 @@
18
18
  표면에 편입시킵니다.
19
19
  - **`pdks covenant check`.** `pdks` bin의 첫 실물 서브커맨드입니다(`polydeukes`는 별칭).
20
20
  pre-commit 판정 러너로, 스테이징 영역의 변경을 `@polydeukes/adapter-git`이 수집해
21
- 약속(covenant) 입력 IR로 번역하고, 세션 훅이 띄우는(spawn) 바로 그 판정 본체로 보냅니다.
22
- 판정기는 하나, 표면은 여럿입니다. 맥락족 규율(discipline, `requirePrecedent`)도 다른 규율과
21
+ 약속(covenant) 입력 IR로 번역하고, 세션 훅이 부르는 바로 그 in-process 판정기로 보냅니다.
22
+ 판정기는 하나, 표면은 여럿입니다. `--worktree`와 `--range <base>..<head>`는 같은 판정을
23
+ 작업 트리나 ref 범위에 진단 호출로 돌리며, 증인 프롬프트는 뜨지 않습니다. 맥락족 규율(discipline, `requirePrecedent`)도 다른 규율과
23
24
  똑같이 조립되지만, 읽을 세션이 없으므로 스킵 등록이 됩니다. 스테이징 변경과 매치하면 자기
24
25
  id와 그 변경을 담은 `skipped` 이벤트를 남기고 커밋은 진행됩니다. 여기서 판정하면 걸리는
25
26
  커밋마다 정당하게 통과할 길 없이 막히고, 아예 걸러내면 게이트가 물러섰다는 사실이 가려집니다.
package/README.md CHANGED
@@ -18,14 +18,22 @@ one-way, through the core alone.
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 judge bodies the session
22
- hook spawns — one judge, every surface. Context-family disciplines (`requirePrecedent`) assemble
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
+ Context-family disciplines (`requirePrecedent`) assemble
23
25
  here like any other, but with no session to read they become skip registrations: 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.
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.
29
37
  - **`pdks init claude-code`** — the 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
package/dist/bin.d.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `pdks` / `polydeukes` — the umbrella bin (ADAPTER-git §4.3).
3
+ * `pdks` / `polydeukes` — the umbrella bin.
4
4
  *
5
- * A thin argv shim over {@link runCovenantCheck}: `covenant check` is the ONLY
6
- * recognized invocation (the wider CLI skeleton is a post-release increment). Anything
7
- * else prints usage and exits 2 an unknown argument must never pass silently
8
- * (fail-closed, the same posture as an unjudgeable payload).
5
+ * A thin argv shim: four subcommands, each 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 (AC-3 human-only arming).
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 (ADAPTER-git §4.3).
3
+ * `pdks` / `polydeukes` — the umbrella bin.
4
4
  *
5
- * A thin argv shim over {@link runCovenantCheck}: `covenant check` is the ONLY
6
- * recognized invocation (the wider CLI skeleton is a post-release increment). Anything
7
- * else prints usage and exits 2 an unknown argument must never pass silently
8
- * (fail-closed, the same posture as an unjudgeable payload).
5
+ * A thin argv shim: four subcommands, each 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 (AC-3 human-only arming).
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
- // A second consultation would land on an already-closed fd; an EBADF thrown
50
- // from this finally would override the `return null` above and escape the
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 (lefthook spawns that on every commit and it must not
61
- // pull the session adapter in PR #46 review). A rejected import outside the try would
62
- // reach node's unhandled-rejection exit 1, the exact crash this bin refuses to make.
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,8 +82,8 @@ 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 (DIST-02 §5-d invariant 2); the message
75
- // names what the user has to do before running this again.
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
  }
@@ -80,40 +91,65 @@ if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
80
91
  if (args[0] === 'docs' && args.length <= 2) {
81
92
  try {
82
93
  // 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, which lefthook
84
- // spawns on every commit.
94
+ // markdown behind it have no business on `covenant check`'s load path.
85
95
  const { queryDocs } = await import('./docs-query.js');
86
96
  // The bundle ships beside this file, so the docs root comes from the module's own
87
97
  // location — never from the working directory, which is whatever shell invoked us.
88
98
  const docsRoot = join(dirname(fileURLToPath(import.meta.url)), 'docs');
89
99
  const { text } = queryDocs({ docsRoot, topic: args[1] });
90
- // A reader that goes away mid-write (a killed pager, `grep -q`, a caller closing its
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);
100
+ await emitAndExit(text);
107
101
  }
108
102
  catch (error) {
109
- // stdout stays at zero bytes on this path (DOCS-02 §3-b): what cannot be answered is
110
- // never answered halfway.
103
+ // stdout stays at zero bytes on this path: what cannot be answered is never answered
104
+ // halfway.
111
105
  process.stderr.write(`pdks docs: ${error instanceof Error ? error.message : String(error)}\n`);
112
106
  process.exit(2);
113
107
  }
114
108
  }
115
- if (args.length !== 2 || args[0] !== 'covenant' || args[1] !== 'check') {
116
- process.stderr.write('usage: pdks covenant check | pdks init claude-code | pdks docs [topic]\n');
109
+ if (args.length === 1 && args[0] === 'explain') {
110
+ try {
111
+ // Imported inside the try for the same reason `docs` is: the renderer pulls in both
112
+ // composition roots, and neither belongs on `covenant check`'s load path.
113
+ const { explain } = await import('./explain.js');
114
+ const { text } = await explain({ repoRoot: process.cwd() });
115
+ await emitAndExit(text);
116
+ }
117
+ catch (error) {
118
+ // stdout stays at zero bytes on this path: what cannot be answered is never answered
119
+ // halfway.
120
+ process.stderr.write(`pdks explain: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ process.exit(2);
122
+ }
123
+ }
124
+ /**
125
+ * Read the `covenant check` flags as a domain, or null when the argv is not one of the
126
+ * three recognized forms: no flags is the staged diff, `--worktree` is the working tree,
127
+ * and `--range <base>..<head>` (or `...` for the merge-base reading) is a ref range.
128
+ */
129
+ function parseCheckDomain(flags) {
130
+ if (flags.length === 0)
131
+ return { kind: 'staged' };
132
+ if (flags.length === 1 && flags[0] === '--worktree')
133
+ return { kind: 'worktree' };
134
+ if (flags.length !== 2 || flags[0] !== '--range')
135
+ return null;
136
+ const range = flags[1];
137
+ if (range.startsWith('--'))
138
+ return null;
139
+ const mergeBase = range.includes('...');
140
+ const separator = mergeBase ? '...' : '..';
141
+ const at = range.indexOf(separator);
142
+ if (at === -1)
143
+ return null;
144
+ const base = range.slice(0, at);
145
+ const head = range.slice(at + separator.length);
146
+ if (base === '' || head === '')
147
+ return null;
148
+ return { kind: 'range', base, head, ...(mergeBase && { ancestry: 'merge-base' }) };
149
+ }
150
+ const domain = args[0] === 'covenant' && args[1] === 'check' ? parseCheckDomain(args.slice(2)) : null;
151
+ 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');
117
153
  process.exit(2);
118
154
  }
119
155
  try {
@@ -127,12 +163,13 @@ try {
127
163
  const { exitCode } = await runCovenantCheck({
128
164
  repoRoot: process.cwd(),
129
165
  ttyPrompt: openTtyPrompt(),
166
+ domain,
130
167
  });
131
168
  process.exit(exitCode);
132
169
  }
133
170
  catch (error) {
134
171
  // Any failure the runner did not already translate is unjudgeable — block, never
135
- // crash into node's exit 1 (the session hook's catch-all posture, AC-7).
172
+ // crash into node's exit 1.
136
173
  process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
137
174
  process.exit(2);
138
175
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `runClaudeCodeHook` — the assembled session-surface judgment runner (DIST-01 §3-c).
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
- * Wiring shape: COVENANT-03 §4.4 + COVENANT-04d §4.5 registrations consumed through
12
- * ADAPTER-03 §4.1 `runAdapterPath`, with `dispatchCovenants` bound to the injected dispatch
13
- * seam. The protection-policy data (protectedPaths / disciplines / witness) is read from the
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 (COVENANT-06, moved behind the verdict by COVENANT-17)
18
- * judged over the JSONL transcript provider (ADAPTER-04). The judge body always spawns, and
19
- * only an outcome that translated to blocked consults the witness `witnessed` rows are
20
- * would-block only. Its defence is provenance rather than secrecy: only a real human
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,11 @@
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
- /** `runClaudeCodeHook` input (DIST-01 §3-c) the `CovenantCheckSpec` shape, session side. */
26
+ import { type CanonicalTranscript } from '@polydeukes/core';
27
+ import { type CovenantRegistration } from '@polydeukes/covenant';
28
+ import { type CovenantModule } from './covenant-module.js';
29
+ import { loadConfig } from './load-config.js';
30
+ /** `runClaudeCodeHook` input — the `CovenantCheckSpec` shape, session side. */
30
31
  export type ClaudeCodeHookSpec = {
31
32
  /** Repository root — config discovery and discipline glob scoping both anchor here. */
32
33
  repoRoot: string;
@@ -37,10 +38,39 @@ export type ClaudeCodeHookSpec = {
37
38
  /** Overrides the resolved covenant dist directory (tests and assembly injection). */
38
39
  covenantDist?: string;
39
40
  };
41
+ /** {@link assembleSessionRegistrations} input — what the session surface's assembly needs. */
42
+ export type SessionAssemblySpec = {
43
+ config: ReturnType<typeof loadConfig>['config'];
44
+ rootDir: string;
45
+ /**
46
+ * The covenant surface the registrations are built from — the module the caller loaded
47
+ * from the resolved dist, so what judges a call is what that dist carries, and what
48
+ * `explain` renders is what would judge it.
49
+ */
50
+ covenant: CovenantModule;
51
+ /** The payload's transcript path. ABSENT leaves the transcript-mod registration out. */
52
+ transcriptPath?: string;
53
+ transcript?: CanonicalTranscript;
54
+ witness?: CovenantRegistration['witness'];
55
+ };
56
+ /**
57
+ * The session surface's registration set. One assembly, two consumers: the runner below
58
+ * dispatches it, `explain` renders it — so what a reader is shown is the table the judgment
59
+ * actually uses, never a second opinion about it.
60
+ */
61
+ export declare function assembleSessionRegistrations(spec: SessionAssemblySpec): CovenantRegistration[];
40
62
  /**
41
- * Judge one declared tool call before it runs (DIST-01 §3-c). Async because the dispatcher
42
- * spawns covenant bodies (CORE-01) — a synchronous runner would mean reimplementing the
43
- * judge, which the single-dispatcher principle forbids.
63
+ * The session-surface entry point: the post-hoc state comparison wrapped around the judgment.
64
+ *
65
+ * The comparison sits OUTSIDE {@link judgeHookCall}'s fail-closed try on both ends. Inside
66
+ * it, a comparison failure would become a blocked call — the opposite of a mechanism whose
67
+ * whole purpose is to record rather than stop — so each side carries its own catch and
68
+ * neither can reach the verdict. Observation is fail-open, the direction
69
+ * `appendRecordFailOpen` already established: the worst outcome is a missing datum.
70
+ *
71
+ * Order is the contract. The comparison runs first, so it reads the window the previous call
72
+ * left and its rows land ahead of this call's judgment; the re-establishment runs last, so
73
+ * this call's own judged writes are folded in rather than alarmed on next time.
44
74
  */
45
75
  export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<{
46
76
  exitCode: 0 | 2;