polydeukes 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.ko.md +14 -28
  2. package/README.md +14 -28
  3. package/dist/baseline.d.ts +37 -1
  4. package/dist/baseline.js +68 -1
  5. package/dist/bin.d.ts +3 -4
  6. package/dist/bin.js +73 -90
  7. package/dist/covenant/bash-line.d.ts +130 -0
  8. package/dist/covenant/bash-line.js +566 -0
  9. package/dist/covenant/declaration-engine.d.ts +108 -0
  10. package/dist/covenant/declaration-engine.js +415 -0
  11. package/dist/covenant/discipline.d.ts +103 -0
  12. package/dist/covenant/discipline.js +667 -0
  13. package/dist/covenant/dispatch.d.ts +153 -0
  14. package/dist/covenant/dispatch.js +237 -0
  15. package/dist/covenant/extract-steps.d.ts +122 -0
  16. package/dist/covenant/extract-steps.js +0 -0
  17. package/dist/covenant/mention.d.ts +117 -0
  18. package/dist/covenant/mention.js +209 -0
  19. package/dist/covenant/module.d.ts +25 -0
  20. package/dist/covenant/module.js +23 -0
  21. package/dist/covenant/mutation-rules.d.ts +40 -0
  22. package/dist/covenant/mutation-rules.js +146 -0
  23. package/dist/covenant/relations.d.ts +46 -0
  24. package/dist/covenant/relations.js +68 -0
  25. package/dist/covenant/run-covenant.d.ts +95 -0
  26. package/dist/covenant/run-covenant.js +164 -0
  27. package/dist/covenant/self-mod.d.ts +55 -0
  28. package/dist/covenant/self-mod.js +84 -0
  29. package/dist/covenant/shell-evidence.d.ts +38 -0
  30. package/dist/covenant/shell-evidence.js +247 -0
  31. package/dist/covenant/shell-mod.d.ts +84 -0
  32. package/dist/covenant/shell-mod.js +213 -0
  33. package/dist/covenant/supply.d.ts +54 -0
  34. package/dist/covenant/supply.js +50 -0
  35. package/dist/covenant/transcript-mod.d.ts +60 -0
  36. package/dist/covenant/transcript-mod.js +254 -0
  37. package/dist/covenant/ttl-witness.d.ts +47 -0
  38. package/dist/covenant/ttl-witness.js +80 -0
  39. package/dist/covenant-check.d.ts +72 -39
  40. package/dist/covenant-check.js +260 -121
  41. package/dist/diff-ir.d.ts +28 -0
  42. package/dist/diff-ir.js +310 -0
  43. package/dist/docs/README.ko.md +8 -8
  44. package/dist/docs/README.md +8 -8
  45. package/dist/docs/catalog.json +44 -28
  46. package/dist/docs/concepts/judgment.ko.md +2 -2
  47. package/dist/docs/concepts/judgment.md +2 -2
  48. package/dist/docs/how-to/configure-project.ko.md +16 -16
  49. package/dist/docs/how-to/configure-project.md +15 -12
  50. package/dist/docs/how-to/connect-surfaces.ko.md +39 -34
  51. package/dist/docs/how-to/connect-surfaces.md +36 -33
  52. package/dist/docs/how-to/write-disciplines.ko.md +61 -6
  53. package/dist/docs/how-to/write-disciplines.md +62 -6
  54. package/dist/docs/index.json +607 -373
  55. package/dist/docs/reference/cli/covenant-check.ko.md +112 -65
  56. package/dist/docs/reference/cli/covenant-check.md +114 -59
  57. package/dist/docs/reference/cli/docs.ko.md +2 -2
  58. package/dist/docs/reference/cli/docs.md +2 -2
  59. package/dist/docs/reference/cli/explain.ko.md +14 -9
  60. package/dist/docs/reference/cli/explain.md +15 -10
  61. package/dist/docs/reference/cli/init.ko.md +52 -52
  62. package/dist/docs/reference/cli/init.md +43 -55
  63. package/dist/docs/reference/configuration/index.ko.md +171 -66
  64. package/dist/docs/reference/configuration/index.md +178 -71
  65. package/dist/docs/reference/packages/adapter-claude-code.ko.md +38 -11
  66. package/dist/docs/reference/packages/adapter-claude-code.md +38 -14
  67. package/dist/docs/reference/packages/adapter-grok.ko.md +77 -0
  68. package/dist/docs/reference/packages/adapter-grok.md +79 -0
  69. package/dist/docs/reference/packages/core.ko.md +49 -3
  70. package/dist/docs/reference/packages/core.md +54 -5
  71. package/dist/docs/reference/packages/polydeukes.ko.md +138 -77
  72. package/dist/docs/reference/packages/polydeukes.md +146 -81
  73. package/dist/docs/reference/packages/sdk-ts.ko.md +161 -0
  74. package/dist/docs/reference/packages/sdk-ts.md +164 -0
  75. package/dist/docs/troubleshooting.ko.md +17 -15
  76. package/dist/docs/troubleshooting.md +18 -14
  77. package/dist/docs/tutorials/first-judgment.ko.md +2 -2
  78. package/dist/docs/tutorials/first-judgment.md +2 -2
  79. package/dist/explain.d.ts +1 -1
  80. package/dist/explain.js +37 -22
  81. package/dist/load-config.d.ts +1 -1
  82. package/dist/load-config.js +1 -1
  83. package/dist/pre-state-reader.d.ts +1 -1
  84. package/dist/pre-state-reader.js +1 -1
  85. package/dist/scaffold-project.d.ts +2 -2
  86. package/dist/scaffold-project.js +9 -3
  87. package/dist/schema/polydeukes.schema.json +12 -0
  88. package/dist/worktree-reader.d.ts +19 -0
  89. package/dist/worktree-reader.js +30 -0
  90. package/package.json +3 -17
  91. package/dist/claude-code-hook.d.ts +0 -79
  92. package/dist/claude-code-hook.js +0 -372
  93. package/dist/claude-code.d.ts +0 -6
  94. package/dist/claude-code.js +0 -6
  95. package/dist/covenant-module.d.ts +0 -25
  96. package/dist/covenant-module.js +0 -42
  97. package/dist/docs/reference/packages/adapter-git.ko.md +0 -101
  98. package/dist/docs/reference/packages/adapter-git.md +0 -108
  99. package/dist/docs/reference/packages/covenant.ko.md +0 -115
  100. package/dist/docs/reference/packages/covenant.md +0 -123
  101. package/dist/index.d.ts +0 -22
  102. package/dist/index.js +0 -21
  103. package/dist/init-claude-code.d.ts +0 -52
  104. package/dist/init-claude-code.js +0 -586
  105. package/dist/init-grok.d.ts +0 -51
  106. package/dist/init-grok.js +0 -242
package/README.ko.md CHANGED
@@ -2,47 +2,33 @@
2
2
 
3
3
  [English](./README.md) · **한국어**
4
4
 
5
- Polydeukes는 우산 패키지입니다. 이 패키지 하나에 `pdks` 실행 파일, 설정 로더,
6
- 커밋 표면 실행기, 세션 표면 실행기 서브패스, 동봉 스키마가 모두 포함돼 있습니다.
5
+ Polydeukes는 우산 패키지입니다. 이 패키지 하나에 `pdks` 실행 파일, 판정기,
6
+ 표면의 조립 루트, 세션 표면 실행기 서브패스, 동봉 스키마가 모두 포함돼 있습니다.
7
7
 
8
8
  <a id="overview"></a>
9
9
  ## 개요
10
10
 
11
- 공개 계약 심볼과 진입점은 다음과 같습니다.
11
+ 공개 계약 진입점은 다음과 같습니다.
12
12
 
13
- - `loadConfig`
14
- - `runCovenantCheck`
15
- - `ResolvedConfig`
16
- - `polydeukes/claude-code` → `runClaudeCodeHook`
13
+ - `pdks` / `polydeukes` — 실행 파일
17
14
  - `polydeukes/schema.json`
15
+
16
+ CLI 명령은 다음과 같습니다.
17
+
18
18
  - `pdks covenant check`
19
- - `pdks init claude-code`
20
- - `pdks init grok`
19
+ - `pdks init`
20
+ - `pdks-grok init` (`@polydeukes/adapter-grok`가 제공)
21
21
  - `pdks explain`
22
22
  - `pdks docs [topic]`
23
23
 
24
24
  <a id="public-symbols"></a>
25
25
  ## 공개 심볼
26
26
 
27
- ```ts
28
- import { loadConfig, runCovenantCheck } from 'polydeukes';
29
- import { runClaudeCodeHook } from 'polydeukes/claude-code';
30
- ```
31
-
32
- ```ts
33
- function loadConfig(spec: { rootDir: string }): {
34
- config: import('@polydeukes/core').ResolvedConfig;
35
- configPath: string;
36
- };
37
-
38
- function runCovenantCheck(spec: {
39
- repoRoot: string;
40
- telemetryPath?: string;
41
- covenantDist?: string;
42
- ttyPrompt?: (prompt: string) => string | null;
43
- domain?: unknown;
44
- }): Promise<{ exitCode: 0 | 2 }>;
45
- ```
27
+ 없습니다. 이 패키지는 TypeScript 진입점을 공개하지 않습니다. `import 'polydeukes'`는
28
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`로 실패하고, 사용자가 닿는 것은 `pdks` 실행 파일과 동봉된
29
+ 스키마입니다. 표면은 판정기에 표준 입력으로 입력을 넘기고 종료 코드를 읽습니다. 에이전트
30
+ 어댑터의 훅이 하는 일이 바로 그것입니다. 이 패키지를 peer 의존으로 선언하고 import 없이
31
+ 실행 파일을 스폰합니다.
46
32
 
47
33
  <a id="see-also"></a>
48
34
  ## 같이 보기
package/README.md CHANGED
@@ -2,48 +2,34 @@
2
2
 
3
3
  **English** · [한국어](./README.ko.md)
4
4
 
5
- Polydeukes is the umbrella package. Install this package; it ships the `pdks` bin, the config
6
- loader, the commit-surface runner, the session-surface runner subpath, and the bundled schema
5
+ Polydeukes is the umbrella package. Install this package; it ships the `pdks` bin, the judge,
6
+ both surfaces' composition roots, the session-surface runner subpath, and the bundled schema
7
7
  artifact.
8
8
 
9
9
  <a id="overview"></a>
10
10
  ## Overview
11
11
 
12
- Public contract symbols and entry points:
12
+ Public contract entry points:
13
13
 
14
- - `loadConfig`
15
- - `runCovenantCheck`
16
- - `ResolvedConfig`
17
- - `polydeukes/claude-code` → `runClaudeCodeHook`
14
+ - `pdks` / `polydeukes` — the bin
18
15
  - `polydeukes/schema.json`
16
+
17
+ CLI commands:
18
+
19
19
  - `pdks covenant check`
20
- - `pdks init claude-code`
21
- - `pdks init grok`
20
+ - `pdks init`
21
+ - `pdks-grok init` (ships with `@polydeukes/adapter-grok`)
22
22
  - `pdks explain`
23
23
  - `pdks docs [topic]`
24
24
 
25
25
  <a id="public-symbols"></a>
26
26
  ## Public symbols
27
27
 
28
- ```ts
29
- import { loadConfig, runCovenantCheck } from 'polydeukes';
30
- import { runClaudeCodeHook } from 'polydeukes/claude-code';
31
- ```
32
-
33
- ```ts
34
- function loadConfig(spec: { rootDir: string }): {
35
- config: import('@polydeukes/core').ResolvedConfig;
36
- configPath: string;
37
- };
38
-
39
- function runCovenantCheck(spec: {
40
- repoRoot: string;
41
- telemetryPath?: string;
42
- covenantDist?: string;
43
- ttyPrompt?: (prompt: string) => string | null;
44
- domain?: unknown;
45
- }): Promise<{ exitCode: 0 | 2 }>;
46
- ```
28
+ None. This package publishes no TypeScript entry point: `import 'polydeukes'` fails with
29
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`, and what a consumer reaches is the `pdks` bin and the
30
+ bundled schema. A surface hands the judge its input on stdin and reads the exit code, which
31
+ is what an agent adapter's hook does — it takes this package as a peer dependency and
32
+ spawns the bin rather than importing it.
47
33
 
48
34
  <a id="see-also"></a>
49
35
  ## See also
@@ -11,7 +11,7 @@
11
11
  * {@link snapshotBaseline} / {@link writeBaseline} / {@link readBaseline} are the I/O
12
12
  * points.
13
13
  */
14
- import type { TelemetryRecord } from '@polydeukes/core';
14
+ import { type TelemetryRecord } from '@polydeukes/core';
15
15
  /** One hash per protected entry, keyed by the entry exactly as configured. */
16
16
  export type BaselineSnapshot = Record<string, string>;
17
17
  /**
@@ -80,3 +80,39 @@ export declare function writeBaseline(path: string, snapshot: BaselineSnapshot,
80
80
  * Observation is fail-open: the worst outcome is a missing datum, never a blocked call.
81
81
  */
82
82
  export declare function readBaseline(path: string): StoredBaseline | null;
83
+ /** The label every post-hoc state comparison row carries. */
84
+ export declare const BASELINE_LABEL = "baseline";
85
+ /**
86
+ * Compare the protected entries' on-disk state against the stored baseline and record what
87
+ * moved with no judgment explaining it.
88
+ *
89
+ * Runs at hook call START, before this call's own judgment rows land, so the window it reads
90
+ * is the one the previous comparison left open. Returns the record count as of right now —
91
+ * where the NEXT window opens, which {@link updateBaseline} persists at call end.
92
+ *
93
+ * The comparison records, it never blocks: no row it writes and no failure it hits changes
94
+ * a verdict or an exit code, which is why every caller keeps it outside the judgment path.
95
+ */
96
+ export declare function compareBaseline(spec: {
97
+ repoRoot: string;
98
+ telemetryPath: string;
99
+ entries: string[];
100
+ }): void;
101
+ /**
102
+ * Re-establish the baseline at hook call END.
103
+ *
104
+ * At call end rather than right after the comparison: refreshing at comparison time would
105
+ * miss whatever this call's own judged writes changed, leaving detection permanently one
106
+ * call behind.
107
+ *
108
+ * The cut is stamped HERE, beside the snapshot, not at the comparison that opened the call.
109
+ * Both describe the same instant — everything this call did is already folded into the
110
+ * hashes — so the rows explaining it belong before the cut. Stamping the earlier instant
111
+ * instead would re-admit this call's own judgment rows into the next window, where they
112
+ * would attribute a change they had nothing to do with: a call that merely MENTIONED a
113
+ * protected entry would then absolve any tamper that followed it.
114
+ */
115
+ export declare function updateBaseline(spec: {
116
+ repoRoot: string;
117
+ entries: string[];
118
+ }): void;
package/dist/baseline.js CHANGED
@@ -12,8 +12,9 @@
12
12
  * points.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
- import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
+ import { appendRecordFailOpen, readRecords, } from '@polydeukes/core';
17
18
  /**
18
19
  * The two verdict words that explain a change — the ones that mean a mutation of a
19
20
  * protected entry was judged and let through anyway.
@@ -164,3 +165,69 @@ export function readBaseline(path) {
164
165
  cutAt: typeof cutAt === 'string' ? cutAt : undefined,
165
166
  };
166
167
  }
168
+ /** The label every post-hoc state comparison row carries. */
169
+ export const BASELINE_LABEL = 'baseline';
170
+ /**
171
+ * Compare the protected entries' on-disk state against the stored baseline and record what
172
+ * moved with no judgment explaining it.
173
+ *
174
+ * Runs at hook call START, before this call's own judgment rows land, so the window it reads
175
+ * is the one the previous comparison left open. Returns the record count as of right now —
176
+ * where the NEXT window opens, which {@link updateBaseline} persists at call end.
177
+ *
178
+ * The comparison records, it never blocks: no row it writes and no failure it hits changes
179
+ * a verdict or an exit code, which is why every caller keeps it outside the judgment path.
180
+ */
181
+ export function compareBaseline(spec) {
182
+ const baselinePath = join(spec.repoRoot, '.polydeukes', 'baseline.json');
183
+ // Read before any row of this comparison lands, so the rows this call is about to write
184
+ // cannot fall inside the window they would then explain away.
185
+ const { records } = readRecords(spec.telemetryPath);
186
+ const stored = readBaseline(baselinePath);
187
+ if (stored === null) {
188
+ // Absence and corruption are the same signal. The baseline file is NOT on the protection
189
+ // list — protecting it would need a comparison of its own — so its disappearance has to
190
+ // stay legible in the log instead.
191
+ appendRecordFailOpen(spec.telemetryPath, {
192
+ event: 'unattributed',
193
+ label: BASELINE_LABEL,
194
+ subject: baselinePath,
195
+ });
196
+ return;
197
+ }
198
+ const changed = findUnattributed({
199
+ previous: stored.entries,
200
+ current: snapshotBaseline({ rootDir: spec.repoRoot, entries: spec.entries }),
201
+ records,
202
+ // The cut travels with the hashes it belongs to, from the one read above. Rows older
203
+ // than it were already spent explaining the state that snapshot recorded.
204
+ cutAt: stored.cutAt,
205
+ });
206
+ // One row per changed entry — an aggregate row could not say WHICH gate definition moved.
207
+ for (const entry of changed) {
208
+ appendRecordFailOpen(spec.telemetryPath, {
209
+ event: 'unattributed',
210
+ label: BASELINE_LABEL,
211
+ subject: entry,
212
+ });
213
+ }
214
+ }
215
+ /**
216
+ * Re-establish the baseline at hook call END.
217
+ *
218
+ * At call end rather than right after the comparison: refreshing at comparison time would
219
+ * miss whatever this call's own judged writes changed, leaving detection permanently one
220
+ * call behind.
221
+ *
222
+ * The cut is stamped HERE, beside the snapshot, not at the comparison that opened the call.
223
+ * Both describe the same instant — everything this call did is already folded into the
224
+ * hashes — so the rows explaining it belong before the cut. Stamping the earlier instant
225
+ * instead would re-admit this call's own judgment rows into the next window, where they
226
+ * would attribute a change they had nothing to do with: a call that merely MENTIONED a
227
+ * protected entry would then absolve any tamper that followed it.
228
+ */
229
+ export function updateBaseline(spec) {
230
+ const dotDir = join(spec.repoRoot, '.polydeukes');
231
+ mkdirSync(dotDir, { recursive: true });
232
+ writeBaseline(join(dotDir, 'baseline.json'), snapshotBaseline({ rootDir: spec.repoRoot, entries: spec.entries }), new Date().toISOString());
233
+ }
package/dist/bin.d.ts CHANGED
@@ -6,9 +6,8 @@
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
  *
9
- * The real TTY is wired HERE, not in the library: the runner receives an injectable
10
- * seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
11
- * CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
12
- * the witness valve is structurally unreachable, so only a human at a terminal can arm it.
9
+ * `covenant check` reads its observation from stdin and nothing else: the IR JSON by
10
+ * default, a unified diff under `--diff`. No other file descriptor is opened, so the
11
+ * process never asks a human anything.
13
12
  */
14
13
  export {};
package/dist/bin.js CHANGED
@@ -6,50 +6,41 @@
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
  *
9
- * The real TTY is wired HERE, not in the library: the runner receives an injectable
10
- * seam, and this shim binds it to /dev/tty. When /dev/tty cannot be opened (git run by
11
- * CI or by an agent-spawned shell — no controlling terminal), the seam stays absent and
12
- * the witness valve is structurally unreachable, so only a human at a terminal can arm it.
9
+ * `covenant check` reads its observation from stdin and nothing else: the IR JSON by
10
+ * default, a unified diff under `--diff`. No other file descriptor is opened, so the
11
+ * process never asks a human anything.
13
12
  */
14
- import { closeSync, openSync, readFileSync, readSync, writeSync } from 'node:fs';
13
+ import { readFileSync, readSync } from 'node:fs';
15
14
  import { dirname, join } from 'node:path';
16
15
  import { fileURLToPath } from 'node:url';
17
16
  /**
18
- * Bind the TTY prompt seam to /dev/tty, or undefined when no terminal exists. The runner
19
- * composes the prompt text (it is the side that knows what broke); this shim only writes
20
- * it and reads the line back.
17
+ * Read stdin to EOF. `readFileSync(0)` returns only what the first read delivers, so a
18
+ * diff larger than the pipe buffer would arrive truncated and translate to a partial
19
+ * observation; this loops until a read answers zero bytes.
21
20
  */
22
- function openTtyPrompt() {
23
- let fd;
24
- try {
25
- fd = openSync('/dev/tty', 'r+');
26
- }
27
- catch {
28
- return undefined;
29
- }
30
- return (prompt) => {
21
+ function readStdin() {
22
+ const chunks = [];
23
+ const buffer = Buffer.alloc(65536);
24
+ for (;;) {
25
+ let bytes;
31
26
  try {
32
- writeSync(fd, prompt);
33
- const buffer = Buffer.alloc(4096);
34
- const bytes = readSync(fd, buffer, 0, buffer.length, null);
35
- return buffer
36
- .subarray(0, Math.max(bytes, 0))
37
- .toString('utf-8')
38
- .replace(/\r?\n$/, '');
27
+ bytes = readSync(0, buffer, 0, buffer.length, null);
39
28
  }
40
- catch {
41
- return null;
29
+ catch (error) {
30
+ const { code } = error;
31
+ // A pipe with no writer left answers EOF this way on some platforms; EAGAIN is a
32
+ // non-blocking fd with nothing ready yet, which is not the end of the input.
33
+ if (code === 'EOF')
34
+ break;
35
+ if (code === 'EAGAIN')
36
+ continue;
37
+ throw error;
42
38
  }
43
- finally {
44
- try {
45
- closeSync(fd);
46
- }
47
- catch {
48
- // An EBADF thrown from this finally would override the `return null` above and
49
- // escape the seam.
50
- }
51
- }
52
- };
39
+ if (bytes === 0)
40
+ break;
41
+ chunks.push(Buffer.from(buffer.subarray(0, bytes)));
42
+ }
43
+ return Buffer.concat(chunks).toString('utf-8');
53
44
  }
54
45
  /**
55
46
  * Write `text` to stdout and end the process — exit 0 once the write drains, exit 2 when
@@ -65,14 +56,14 @@ async function emitAndExit(text) {
65
56
  process.exit(0);
66
57
  }
67
58
  const args = process.argv.slice(2);
68
- if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
59
+ if (args.length === 1 && args[0] === 'init') {
69
60
  try {
70
- // Imported inside the try, not above it: ESM imports are eager, so the installer stays
61
+ // Imported inside the try, not above it: ESM imports are eager, so the scaffold stays
71
62
  // off `covenant check`'s load path, which a pre-commit hook spawns on every commit. A
72
63
  // rejected import outside the try would reach node's unhandled-rejection exit 1, the
73
64
  // exact crash this bin refuses to make.
74
- const { initClaudeCode } = await import('./init-claude-code.js');
75
- const { created, skipped } = initClaudeCode({ projectRoot: process.cwd() });
65
+ const { scaffoldProject } = await import('./scaffold-project.js');
66
+ const { created, skipped } = scaffoldProject(process.cwd());
76
67
  for (const path of created) {
77
68
  process.stdout.write(`created ${path}\n`);
78
69
  }
@@ -84,24 +75,7 @@ if (args.length === 2 && args[0] === 'init' && args[1] === 'claude-code') {
84
75
  catch (error) {
85
76
  // A precondition failure leaves zero files; the message names what the user has to do
86
77
  // before running this again.
87
- process.stderr.write(`pdks init claude-code failed: ${error instanceof Error ? error.message : String(error)}\n`);
88
- process.exit(2);
89
- }
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`);
78
+ process.stderr.write(`pdks init failed: ${error instanceof Error ? error.message : String(error)}\n`);
105
79
  process.exit(2);
106
80
  }
107
81
  }
@@ -142,48 +116,57 @@ if (args.length === 1 && args[0] === 'explain') {
142
116
  }
143
117
  }
144
118
  /**
145
- * Read the `covenant check` flags as a domain, or null when the argv is not one of the
146
- * three recognized forms: no flags is the staged diff, `--worktree` is the working tree,
147
- * and `--range <base>..<head>` (or `...` for the merge-base reading) is a ref range.
119
+ * Read the `covenant check` flags, or null for any argv outside the finite table: `--diff`
120
+ * at most once, `--enforce` at most once with `advise` or `block`, in either order, and
121
+ * nothing else.
148
122
  */
149
- function parseCheckDomain(flags) {
150
- if (flags.length === 0)
151
- return { kind: 'staged' };
152
- if (flags.length === 1 && flags[0] === '--worktree')
153
- return { kind: 'worktree' };
154
- if (flags.length !== 2 || flags[0] !== '--range')
155
- return null;
156
- const range = flags[1];
157
- if (range.startsWith('--'))
158
- return null;
159
- const mergeBase = range.includes('...');
160
- const separator = mergeBase ? '...' : '..';
161
- const at = range.indexOf(separator);
162
- if (at === -1)
163
- return null;
164
- const base = range.slice(0, at);
165
- const head = range.slice(at + separator.length);
166
- if (base === '' || head === '')
123
+ function parseCheckFlags(flags) {
124
+ let diffMode = false;
125
+ let enforce;
126
+ for (let i = 0; i < flags.length; i += 1) {
127
+ const flag = flags[i];
128
+ if (flag === '--diff' && !diffMode) {
129
+ diffMode = true;
130
+ continue;
131
+ }
132
+ if (flag === '--enforce' && enforce === undefined) {
133
+ const level = flags[i + 1];
134
+ if (level !== 'advise' && level !== 'block')
135
+ return null;
136
+ enforce = level;
137
+ i += 1;
138
+ continue;
139
+ }
167
140
  return null;
168
- return { kind: 'range', base, head, ...(mergeBase && { ancestry: 'merge-base' }) };
141
+ }
142
+ return { diffMode, enforce };
169
143
  }
170
- const domain = args[0] === 'covenant' && args[1] === 'check' ? parseCheckDomain(args.slice(2)) : null;
171
- if (domain === null) {
172
- process.stderr.write('usage: pdks covenant check [--worktree | --range <base>..<head>] | pdks explain | pdks init claude-code | pdks init grok | pdks docs [topic | search <query> | show <document-id>]\n');
144
+ const check = args[0] === 'covenant' && args[1] === 'check' ? parseCheckFlags(args.slice(2)) : null;
145
+ if (check === null) {
146
+ process.stderr.write('usage: pdks covenant check [--diff] [--enforce advise|block] | pdks explain | pdks init | pdks docs [topic | search <query> | show <document-id>]\n');
173
147
  process.exit(2);
174
148
  }
149
+ const { diffMode, enforce } = check;
175
150
  try {
176
151
  // Loaded here rather than at the top of the file. This runner statically pulls in the
177
- // git adapter, the core, and the judge, so a top-level import made every subcommand
178
- // wait on all three resolving — and `docs` is the one that has to answer in a tree
179
- // where they do not, since a package installed but never built is exactly the state
180
- // `pdks docs install` is asked about. The catch below already answers for whatever
181
- // this import cannot do, at the same exit 2 it answers everything else with.
152
+ // core and the judge, so a top-level import made every subcommand wait on both
153
+ // resolving — and `docs` is the one that has to answer in a tree where they do not,
154
+ // since a package installed but never built is exactly the state `pdks docs install`
155
+ // is asked about. The catch below already answers for whatever this import cannot do,
156
+ // at the same exit 2 it answers everything else with.
182
157
  const { runCovenantCheck } = await import('./covenant-check.js');
158
+ const { covenantInputFromUnifiedDiff } = await import('./diff-ir.js');
159
+ const text = readStdin();
183
160
  const { exitCode } = await runCovenantCheck({
184
161
  repoRoot: process.cwd(),
185
- ttyPrompt: openTtyPrompt(),
186
- domain,
162
+ // A thunk, not a value: the runner settles the telemetry path before calling it, so a
163
+ // translation or parse failure lands as the same one blocked row every other
164
+ // fail-closed branch leaves.
165
+ input: () => (diffMode ? covenantInputFromUnifiedDiff({ text }) : JSON.parse(text)),
166
+ // The input mode is the surface: a diff is a finished change set, anything else is one
167
+ // call the caller observed.
168
+ surface: diffMode ? 'changeSet' : 'session',
169
+ ...(enforce !== undefined && { enforce }),
187
170
  });
188
171
  process.exit(exitCode);
189
172
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Bash command-line tokenizer + mutation-target extraction core.
3
+ *
4
+ * Pure functions only — zero I/O, spawn, or logging. A hand-rolled single-pass character
5
+ * scanner recognizes quote state (`'`, `"`, `$'…'`, `\`), control operators
6
+ * (`;` `&&` `||` `|` `&`), redirect operators (`>` `>>` `>|` `<` `2>` `&>`, attached `>f`),
7
+ * and marks tokens opaque when their static value is unknowable (command substitution,
8
+ * parameter expansion, globs).
9
+ *
10
+ * Fail-closed: no input ever throws. A construct the scanner cannot finish reading yields a
11
+ * partial result — the commands it did read, plus one `unread` span per failure; in
12
+ * {@link extractMutations} each span becomes one indeterminate entry. Block/allow
13
+ * decisions, read-only allowlists, and detection rules live in the judges that consume this.
14
+ */
15
+ /** A single word token with a static-opacity flag (`opaque` = value not knowable). */
16
+ export type WordToken = {
17
+ text: string;
18
+ opaque: boolean;
19
+ };
20
+ /** A redirect operator paired with its target word (`>` `>>` `<` `2>` `&>`). */
21
+ export type RedirectToken = {
22
+ operator: string;
23
+ target: WordToken;
24
+ };
25
+ /**
26
+ * One heredoc body a command declared, with its delimiter's quoting. `literal` means the
27
+ * delimiter was quoted, so the body is written verbatim; an unquoted delimiter expands its
28
+ * body, which only a consumer can decide what to do about.
29
+ */
30
+ export type HeredocBody = {
31
+ body: string;
32
+ literal: boolean;
33
+ };
34
+ /**
35
+ * One simple command: its word tokens, any redirect operators, and the heredoc bodies it
36
+ * declared (in declaration order — absent when it declared none).
37
+ */
38
+ export type SimpleCommand = {
39
+ words: WordToken[];
40
+ redirects: RedirectToken[];
41
+ heredocs?: HeredocBody[];
42
+ };
43
+ /**
44
+ * A span of the line the scanner could not read, with the reason it stopped. `reason` is a
45
+ * telemetry pass-through value: no consumer branches on it.
46
+ */
47
+ export type UnreadSpan = {
48
+ text: string;
49
+ reason: string;
50
+ };
51
+ /**
52
+ * A half-open span `[start, end)` of the line bash hands to a command as stdin data instead
53
+ * of executing: a heredoc body, or a herestring's target word.
54
+ */
55
+ export type DataSpan = {
56
+ start: number;
57
+ end: number;
58
+ };
59
+ /**
60
+ * The tokenizer's result: the commands it read, plus one span per failure it hit. A failure
61
+ * does not discard the line — the read commands reach precise judgment and only the spans
62
+ * fall to a consumer's conservative treatment. An empty `unread` is the "fully read" signal.
63
+ *
64
+ * `data` lists the stdin-data spans in source order, non-overlapping: heredoc bodies and
65
+ * herestring words bash hands over without expanding them. A body under an unquoted
66
+ * delimiter that carries `$` or a backtick, and an opaque herestring word, are text bash
67
+ * runs first and are not data. A span the scanner could not finish reading is never among
68
+ * them either, so unread bytes stay on the command line.
69
+ */
70
+ export type TokenizeResult = {
71
+ commands: SimpleCommand[];
72
+ unread: UnreadSpan[];
73
+ data: DataSpan[];
74
+ };
75
+ /** A detected mutation target (path) with the name of the rule that found it. */
76
+ export type MutationTarget = {
77
+ path: string;
78
+ rule: string;
79
+ };
80
+ /**
81
+ * A detection rule seam: a pure function over a single simple command that returns the
82
+ * mutation targets it detects.
83
+ */
84
+ export type MutationRule = {
85
+ name: string;
86
+ detect(command: SimpleCommand): MutationTarget[];
87
+ };
88
+ /** A structure that cannot be decided deterministically, with the reason why. */
89
+ export type Indeterminate = {
90
+ reason: string;
91
+ };
92
+ /** The extraction result — detected mutations and undecidable structures, kept separate. */
93
+ export type MutationAnalysis = {
94
+ mutations: MutationTarget[];
95
+ indeterminate: Indeterminate[];
96
+ };
97
+ /**
98
+ * True when `commandName` (a command word's basename) re-parses its string arguments in a
99
+ * nested shell — the reinterpretation boundary the tokenizer refuses to parse into. A judge
100
+ * can consult this to refuse to treat such a command as provably read-only.
101
+ */
102
+ export declare function isNestedShellCommand(commandName: string): boolean;
103
+ /**
104
+ * The word that names the command, skipping any leading assignments — undefined when the
105
+ * command is nothing but assignments.
106
+ *
107
+ * Read at the nested-shell boundary only. There an unskipped assignment hides `bash` behind
108
+ * `FOO=1` and the line passes with confidence; for the read-only allowlist and precedent
109
+ * evidence the same miss is the conservative direction, so those keep reading `words[0]`.
110
+ */
111
+ export declare function commandNameWord(command: SimpleCommand): WordToken | undefined;
112
+ /**
113
+ * Tokenize one shell line into simple commands. Never throws, and never discards what it
114
+ * read: a construct it cannot finish reading is recorded as an `unread` span and the scan
115
+ * carries on.
116
+ */
117
+ export declare function tokenizeCommandLine(line: string): TokenizeResult;
118
+ /**
119
+ * The executed text of a command line: the line with every `data` span deleted and nothing
120
+ * put in its place, so the words bash runs keep their spelling and line structure. Bytes
121
+ * the scanner could not finish reading are not data spans and stay as written.
122
+ */
123
+ export declare function executedText(line: string): string;
124
+ /**
125
+ * Extract mutation targets from a shell line via injected rules. A simple command
126
+ * contributes an indeterminate entry when it is a nested-shell call OR contains any opaque
127
+ * word (in which case its rules are still applied, but an undecidable structure is present);
128
+ * each unread span yields one more. Never throws.
129
+ */
130
+ export declare function extractMutations(line: string, rules: MutationRule[]): MutationAnalysis;