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.
Files changed (41) hide show
  1. package/README.ko.md +11 -4
  2. package/README.md +21 -5
  3. package/dist/baseline.d.ts +82 -0
  4. package/dist/baseline.js +166 -0
  5. package/dist/bin.d.ts +5 -6
  6. package/dist/bin.js +92 -38
  7. package/dist/claude-code-hook.d.ts +40 -17
  8. package/dist/claude-code-hook.js +187 -175
  9. package/dist/claude-code.d.ts +6 -0
  10. package/dist/claude-code.js +6 -0
  11. package/dist/covenant-check.d.ts +50 -36
  12. package/dist/covenant-check.js +167 -193
  13. package/dist/covenant-module.d.ts +25 -0
  14. package/dist/covenant-module.js +42 -0
  15. package/dist/docs/configuration.md +17 -9
  16. package/dist/docs/installation.md +42 -12
  17. package/dist/docs/reference/adapter-claude-code.md +6 -4
  18. package/dist/docs/reference/adapter-git.md +23 -10
  19. package/dist/docs/reference/configuration.md +265 -103
  20. package/dist/docs/reference/core.md +15 -7
  21. package/dist/docs/reference/covenant.md +32 -24
  22. package/dist/docs/reference/polydeukes.md +132 -32
  23. package/dist/docs/troubleshooting.md +37 -8
  24. package/dist/docs-query.d.ts +10 -10
  25. package/dist/docs-query.js +12 -12
  26. package/dist/explain.d.ts +25 -0
  27. package/dist/explain.js +153 -0
  28. package/dist/index.d.ts +11 -16
  29. package/dist/index.js +10 -15
  30. package/dist/init-claude-code.d.ts +31 -18
  31. package/dist/init-claude-code.js +254 -40
  32. package/dist/init-grok.d.ts +51 -0
  33. package/dist/init-grok.js +242 -0
  34. package/dist/load-config.d.ts +17 -15
  35. package/dist/load-config.js +13 -12
  36. package/dist/pre-state-reader.d.ts +22 -0
  37. package/dist/pre-state-reader.js +32 -0
  38. package/dist/scaffold-project.d.ts +23 -14
  39. package/dist/scaffold-project.js +97 -32
  40. package/dist/schema/polydeukes.schema.json +54 -81
  41. package/package.json +7 -7
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `initGrok` — the Grok session-surface installer.
3
+ *
4
+ * Preflight first, then the shared project-side scaffold, then the generated hook and the
5
+ * `.grok/hooks` JSON registration. When a Claude delegator is already on disk, the JSON
6
+ * command names that file instead of planting a second one — two command strings would
7
+ * spawn two judges per call.
8
+ *
9
+ * Nothing existing is overwritten, with one command-field exception: a grok JSON whose
10
+ * `command` is still the grok-mjs string is rewritten to the Claude-hook command when that
11
+ * Claude file is on disk — otherwise grok-then-claude leaves two command strings. Any entry
12
+ * naming the Claude-hook command also takes the matcher of the `.claude/settings.json` entry
13
+ * carrying that command, because the host collapses the two registrations only when command
14
+ * and matcher are byte-identical; with no such entry the matcher stays. Timeout stays either
15
+ * way, and a command that is not the grok-mjs string is left byte-identical.
16
+ *
17
+ * Rules and skills are not copied; `.claude/settings.json` is not written.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { findPackageJSON } from 'node:module';
21
+ import { dirname, join } from 'node:path';
22
+ import { isPlainObject } from '@polydeukes/core';
23
+ import { scaffoldProject } from './scaffold-project.js';
24
+ /** The published entry point the generated hook loads the judge through. */
25
+ const HOOK_SPECIFIER = 'polydeukes/claude-code';
26
+ /** The registration artifacts, as `projectRoot`-relative paths (the report vocabulary). */
27
+ const GROK_HOOK_RELATIVE = '.grok/hooks/covenant-pretooluse.mjs';
28
+ const GROK_JSON_RELATIVE = '.grok/hooks/covenant-pretooluse.json';
29
+ /** The Claude delegator this installer reuses when it is already on disk. */
30
+ const CLAUDE_HOOK_RELATIVE = '.claude/hooks/covenant-pretooluse.mjs';
31
+ /** Where that delegator's own registration — and the matcher it was registered under — lives. */
32
+ const CLAUDE_SETTINGS_RELATIVE = '.claude/settings.json';
33
+ const GROK_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR"/${GROK_HOOK_RELATIVE}`;
34
+ const CLAUDE_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR"/${CLAUDE_HOOK_RELATIVE}`;
35
+ /**
36
+ * Which calls reach the judge — Grok's mutating+shell roster plus the Claude aliases a
37
+ * host without Grok's alias table still sends.
38
+ */
39
+ const HOOK_MATCHER = 'write|search_replace|run_terminal_command|Edit|Write|MultiEdit|NotebookEdit|Bash';
40
+ /**
41
+ * The generated hook. It carries no assembly at all, so upgrading the package upgrades the
42
+ * judge without regenerating this file. Same text as the Claude installer besides the
43
+ * header: the session subpath, `repoRoot` from this file's location, and `process.exitCode`
44
+ * rather than `process.exit`.
45
+ */
46
+ const GENERATED_HOOK = `#!/usr/bin/env node
47
+ /**
48
+ * Polydeukes PreToolUse covenant hook — generated by \`pdks init grok\`.
49
+ *
50
+ * A delegator and nothing more: the judgment assembly lives in the \`polydeukes\` package as
51
+ * \`runClaudeCodeHook\`, reached here through its session subpath. The package barrel would
52
+ * work too and is the wrong door — its re-exports are eager, so every session call would
53
+ * load the commit surface and its git adapter alongside the judge it actually needs.
54
+ *
55
+ * \`repoRoot\` comes from this file's own location, never from the working directory. A hook
56
+ * is spawned with whatever directory the agent happened to hold, and what config discovery
57
+ * and the protection list need is the project that CONTAINS this hook — always \`../..\`
58
+ * from here.
59
+ *
60
+ * fail-closed: \`runClaudeCodeHook\` translates every failure it can reach into exit 2 with
61
+ * one blocked record. This catch answers only for what it cannot reach — the package failing
62
+ * to resolve or load at all (never installed, or installed without a build) — where no
63
+ * telemetry writer exists yet. Recovery is installing the package again.
64
+ */
65
+
66
+ import { dirname, join } from 'node:path';
67
+ import { fileURLToPath } from 'node:url';
68
+
69
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
70
+
71
+ try {
72
+ const { runClaudeCodeHook } = await import('${HOOK_SPECIFIER}');
73
+ const { exitCode } = await runClaudeCodeHook({ repoRoot });
74
+ // Assign and let the process end naturally instead of process.exit(): an explicit exit
75
+ // can preempt a buffered stderr write on platforms with async pipes, dropping the break
76
+ // reason the agent needs to read.
77
+ process.exitCode = exitCode;
78
+ } catch (error) {
79
+ console.error(\`covenant hook failed closed: \${error?.message ?? error}\`);
80
+ process.exitCode = 2;
81
+ }
82
+ `;
83
+ /**
84
+ * The default preflight: is `polydeukes` installed where `projectRoot` can reach it?
85
+ *
86
+ * ESM resolution specifically, because that is what the generated hook's `await import(...)`
87
+ * runs. The generated hook loads `polydeukes/claude-code`, so that is the subpath this
88
+ * checks — a Grok-named entry point is not added.
89
+ */
90
+ function resolveFromProjectRoot(projectRoot) {
91
+ const manifestPath = findPackageJSON('polydeukes', join(projectRoot, 'package.json'));
92
+ if (manifestPath === undefined) {
93
+ throw new Error('polydeukes is not installed where this project can reach it');
94
+ }
95
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
96
+ const subpath = isPlainObject(manifest) && isPlainObject(manifest.exports)
97
+ ? manifest.exports[`./${HOOK_SPECIFIER.split('/')[1]}`]
98
+ : undefined;
99
+ const target = isPlainObject(subpath) ? subpath.import : undefined;
100
+ if (typeof target !== 'string' || !existsSync(join(dirname(manifestPath), target))) {
101
+ throw new Error(`the installed polydeukes does not expose '${HOOK_SPECIFIER}' — update or rebuild it`);
102
+ }
103
+ }
104
+ /** Write one generated artifact unless it is already there, recording which happened. */
105
+ function writeIfAbsent(projectRoot, relative, contents, report) {
106
+ const path = join(projectRoot, relative);
107
+ if (existsSync(path)) {
108
+ report.skipped.push(relative);
109
+ return;
110
+ }
111
+ mkdirSync(dirname(path), { recursive: true });
112
+ writeFileSync(path, contents);
113
+ report.created.push(relative);
114
+ }
115
+ /**
116
+ * The matcher `.claude/settings.json` registered `command` under, when it has one. Anything
117
+ * unreadable, unparseable, or shaped otherwise answers `undefined` — the caller then keeps its
118
+ * own roster rather than narrowing the registration to a shape it could not read.
119
+ */
120
+ function claudeSettingsMatcherFor(projectRoot, command) {
121
+ const path = join(projectRoot, CLAUDE_SETTINGS_RELATIVE);
122
+ if (!existsSync(path)) {
123
+ return undefined;
124
+ }
125
+ let root;
126
+ try {
127
+ root = JSON.parse(readFileSync(path, 'utf-8'));
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ if (!isPlainObject(root) || !isPlainObject(root.hooks) || !Array.isArray(root.hooks.PreToolUse)) {
133
+ return undefined;
134
+ }
135
+ for (const entry of root.hooks.PreToolUse) {
136
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
137
+ continue;
138
+ }
139
+ if (entry.hooks.some((hook) => isPlainObject(hook) && hook.command === command)) {
140
+ return typeof entry.matcher === 'string' ? entry.matcher : undefined;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ function grokHookJson(command, matcher) {
146
+ return `${JSON.stringify({
147
+ hooks: {
148
+ PreToolUse: [
149
+ {
150
+ matcher,
151
+ hooks: [{ type: 'command', command, timeout: 60 }],
152
+ },
153
+ ],
154
+ },
155
+ }, null, 2)}\n`;
156
+ }
157
+ /**
158
+ * Rewrite the grok JSON `command` from the grok-mjs string to the Claude-hook string.
159
+ *
160
+ * The Claude installer calls this after writing its delegator; this installer calls it on
161
+ * re-run when that file is already on disk. Only the installer-generated grok-mjs command
162
+ * is rewritten; any other string is the consumer's spawn target and the file is not touched.
163
+ * Every entry naming the Claude-hook command — rewritten now or by an earlier install — takes
164
+ * the matcher the Claude settings file registered that command under, so the host sees one
165
+ * pair rather than two and a re-run converges. Parse failure leaves the file as it was —
166
+ * existence is presence, not parse success.
167
+ */
168
+ export function retargetGrokHookCommandToClaude(projectRoot) {
169
+ const path = join(projectRoot, GROK_JSON_RELATIVE);
170
+ if (!existsSync(path)) {
171
+ return;
172
+ }
173
+ let root;
174
+ try {
175
+ root = JSON.parse(readFileSync(path, 'utf-8'));
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ if (!isPlainObject(root) || !isPlainObject(root.hooks) || !Array.isArray(root.hooks.PreToolUse)) {
181
+ return;
182
+ }
183
+ const settingsMatcher = claudeSettingsMatcherFor(projectRoot, CLAUDE_HOOK_COMMAND);
184
+ let changed = false;
185
+ for (const entry of root.hooks.PreToolUse) {
186
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
187
+ continue;
188
+ }
189
+ let namesClaudeHook = false;
190
+ for (const hook of entry.hooks) {
191
+ if (!isPlainObject(hook)) {
192
+ continue;
193
+ }
194
+ if (hook.command === GROK_HOOK_COMMAND) {
195
+ hook.command = CLAUDE_HOOK_COMMAND;
196
+ changed = true;
197
+ }
198
+ namesClaudeHook ||= hook.command === CLAUDE_HOOK_COMMAND;
199
+ }
200
+ // Whether this pass rewrote the command or an earlier install already did, an entry
201
+ // naming the Claude hook pairs with the settings entry only on the same matcher.
202
+ if (namesClaudeHook && settingsMatcher !== undefined && entry.matcher !== settingsMatcher) {
203
+ entry.matcher = settingsMatcher;
204
+ changed = true;
205
+ }
206
+ }
207
+ if (changed) {
208
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
209
+ }
210
+ }
211
+ /**
212
+ * Install the Grok session surface into `spec.projectRoot`, skipping whatever is already
213
+ * there and reporting both halves per artifact.
214
+ *
215
+ * Throws before any write when the package cannot be resolved from that root — that leaves
216
+ * zero files. Translating a throw into exit 2 with the install command is the bin's job.
217
+ */
218
+ export function initGrok(spec) {
219
+ const resolvePolydeukes = spec.resolvePolydeukes ?? resolveFromProjectRoot;
220
+ try {
221
+ resolvePolydeukes(spec.projectRoot);
222
+ }
223
+ catch (error) {
224
+ throw new Error(`cannot use 'polydeukes' from ${spec.projectRoot} — install or update it there first ` +
225
+ "(e.g. 'npm install --save-dev polydeukes'), then run this command again: " +
226
+ `${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ const report = scaffoldProject(spec.projectRoot);
229
+ const claudeHookExists = existsSync(join(spec.projectRoot, CLAUDE_HOOK_RELATIVE));
230
+ if (!claudeHookExists) {
231
+ writeIfAbsent(spec.projectRoot, GROK_HOOK_RELATIVE, GENERATED_HOOK, report);
232
+ }
233
+ const command = claudeHookExists ? CLAUDE_HOOK_COMMAND : GROK_HOOK_COMMAND;
234
+ const matcher = claudeHookExists
235
+ ? (claudeSettingsMatcherFor(spec.projectRoot, CLAUDE_HOOK_COMMAND) ?? HOOK_MATCHER)
236
+ : HOOK_MATCHER;
237
+ writeIfAbsent(spec.projectRoot, GROK_JSON_RELATIVE, grokHookJson(command, matcher), report);
238
+ if (claudeHookExists) {
239
+ retargetGrokHookCommandToClaude(spec.projectRoot);
240
+ }
241
+ return report;
242
+ }
@@ -1,24 +1,26 @@
1
1
  /**
2
- * Config discovery and loading (CONFIG-03) — the one place allowed to read and parse the
3
- * data config file, so the core stays file-I/O-free.
2
+ * Config discovery and loading — the one place allowed to read and parse the data config
3
+ * file, so the core stays file-I/O-free.
4
4
  *
5
5
  * This lives in its own module rather than in the package barrel because ESM re-exports are
6
- * eager: when `index.ts` re-exports both composition roots, anything importing `loadConfig`
7
- * from the barrel instantiates the session adapter too. That put `@polydeukes/adapter-claude-code`
8
- * on the commit surface's load path, where it is never used a workspace missing only that
9
- * dist would kill `pdks covenant check` before its fail-closed handler could record a row
10
- * (PR #46 review). Both composition roots import this module directly for the same reason.
6
+ * eager: importing `loadConfig` from the barrel would instantiate both composition roots,
7
+ * putting the session adapter on the commit surface's load path where it is never used. A
8
+ * workspace missing only that dist would then kill `pdks covenant check` before its
9
+ * fail-closed handler could record a row. Both composition roots import this module directly
10
+ * for the same reason.
11
11
  */
12
12
  import type { ResolvedConfig } from '@polydeukes/core';
13
13
  /**
14
14
  * The three accepted config filenames, checked directly under the given rootDir. Exported
15
- * for the scaffold (DIST-02 §3-a): its existence check has to see exactly what discovery
16
- * sees, or it would create a second spelling and make every later load ambiguous.
15
+ * for the scaffold: its existence check has to see exactly what discovery sees, or it would
16
+ * create a second spelling and make every later load ambiguous.
17
17
  */
18
18
  export declare const CONFIG_FILENAMES: readonly ['polydeukes.config.yaml', 'polydeukes.config.yml', 'polydeukes.config.json'];
19
- /**
20
- * `LoadedConfig` the loader's return value (CONFIG-03 §4.1).
21
- */
19
+ /** {@link loadConfig} input — the directory the config is discovered in. */
20
+ export type LoadConfigSpec = {
21
+ rootDir: string;
22
+ };
23
+ /** `LoadedConfig` — the loader's return value. */
22
24
  export type LoadedConfig = {
23
25
  /** defineConfig() resolution — protectedPaths already includes configPath */
24
26
  config: ResolvedConfig;
@@ -26,7 +28,7 @@ export type LoadedConfig = {
26
28
  configPath: string;
27
29
  };
28
30
  /**
29
- * Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
31
+ * Discover, parse, and validate the Polydeukes data config in `rootDir`.
30
32
  *
31
33
  * Discovery looks at exactly the three candidate filenames directly under `rootDir`
32
34
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
@@ -38,6 +40,6 @@ export type LoadedConfig = {
38
40
  *
39
41
  * Before returning, the discovered `configPath` is appended to
40
42
  * `config.protectedPaths` unless already present — the config file itself joins the
41
- * protection surface (schema rule 6), guaranteed here so no assembler has to remember.
43
+ * protection surface, guaranteed here so no assembler has to remember.
42
44
  */
43
- export declare function loadConfig(rootDir: string): LoadedConfig;
45
+ export declare function loadConfig(spec: LoadConfigSpec): LoadedConfig;
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Config discovery and loading (CONFIG-03) — the one place allowed to read and parse the
3
- * data config file, so the core stays file-I/O-free.
2
+ * Config discovery and loading — the one place allowed to read and parse the data config
3
+ * file, so the core stays file-I/O-free.
4
4
  *
5
5
  * This lives in its own module rather than in the package barrel because ESM re-exports are
6
- * eager: when `index.ts` re-exports both composition roots, anything importing `loadConfig`
7
- * from the barrel instantiates the session adapter too. That put `@polydeukes/adapter-claude-code`
8
- * on the commit surface's load path, where it is never used a workspace missing only that
9
- * dist would kill `pdks covenant check` before its fail-closed handler could record a row
10
- * (PR #46 review). Both composition roots import this module directly for the same reason.
6
+ * eager: importing `loadConfig` from the barrel would instantiate both composition roots,
7
+ * putting the session adapter on the commit surface's load path where it is never used. A
8
+ * workspace missing only that dist would then kill `pdks covenant check` before its
9
+ * fail-closed handler could record a row. Both composition roots import this module directly
10
+ * for the same reason.
11
11
  */
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
@@ -15,8 +15,8 @@ import { ConfigValidationError, defineConfig, isPlainObject } from '@polydeukes/
15
15
  import { parseDocument } from 'yaml';
16
16
  /**
17
17
  * The three accepted config filenames, checked directly under the given rootDir. Exported
18
- * for the scaffold (DIST-02 §3-a): its existence check has to see exactly what discovery
19
- * sees, or it would create a second spelling and make every later load ambiguous.
18
+ * for the scaffold: its existence check has to see exactly what discovery sees, or it would
19
+ * create a second spelling and make every later load ambiguous.
20
20
  */
21
21
  export const CONFIG_FILENAMES = [
22
22
  'polydeukes.config.yaml',
@@ -24,7 +24,7 @@ export const CONFIG_FILENAMES = [
24
24
  'polydeukes.config.json',
25
25
  ];
26
26
  /**
27
- * Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
27
+ * Discover, parse, and validate the Polydeukes data config in `rootDir`.
28
28
  *
29
29
  * Discovery looks at exactly the three candidate filenames directly under `rootDir`
30
30
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
@@ -36,9 +36,10 @@ export const CONFIG_FILENAMES = [
36
36
  *
37
37
  * Before returning, the discovered `configPath` is appended to
38
38
  * `config.protectedPaths` unless already present — the config file itself joins the
39
- * protection surface (schema rule 6), guaranteed here so no assembler has to remember.
39
+ * protection surface, guaranteed here so no assembler has to remember.
40
40
  */
41
- export function loadConfig(rootDir) {
41
+ export function loadConfig(spec) {
42
+ const { rootDir } = spec;
42
43
  const found = CONFIG_FILENAMES.filter((name) => existsSync(join(rootDir, name)));
43
44
  if (found.length === 0) {
44
45
  throw new Error(`no Polydeukes config found in ${rootDir} — expected one of: ${CONFIG_FILENAMES.join(', ')}`);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The pre-state readers the two surfaces inject into the discipline compiler — the disk
3
+ * access that the judge package must not carry.
4
+ */
5
+ /**
6
+ * The session surface's reader: the hook runs before the tool does, so the working tree at
7
+ * an absolute location IS the pre-state.
8
+ *
9
+ * Three answers, three consequences. Text is a modify; `null` — the file is not there — is a
10
+ * create, so nothing is forgiven as pre-existing debt; `undefined` is a location that cannot
11
+ * be read at all, which the judge escalates to the fail-closed exit. A permission error or a
12
+ * race collapsed into either of the first two would record the run as `passed`.
13
+ */
14
+ export declare function sessionPreStateReader(location: string): string | null | undefined;
15
+ /**
16
+ * The commit surface's reader: it observes a staged diff, whose payloads already carry the
17
+ * pre their own observation saw, and it registers no shell axis — so no derivation ever asks
18
+ * for a pre-state here. Should one arrive, the working tree is not what this surface judges,
19
+ * and answering from it would compare the diff against the wrong baseline; `undefined` says
20
+ * so and fails that call closed.
21
+ */
22
+ export declare function unobservedPreStateReader(): undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The pre-state readers the two surfaces inject into the discipline compiler — the disk
3
+ * access that the judge package must not carry.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ /**
7
+ * The session surface's reader: the hook runs before the tool does, so the working tree at
8
+ * an absolute location IS the pre-state.
9
+ *
10
+ * Three answers, three consequences. Text is a modify; `null` — the file is not there — is a
11
+ * create, so nothing is forgiven as pre-existing debt; `undefined` is a location that cannot
12
+ * be read at all, which the judge escalates to the fail-closed exit. A permission error or a
13
+ * race collapsed into either of the first two would record the run as `passed`.
14
+ */
15
+ export function sessionPreStateReader(location) {
16
+ try {
17
+ return readFileSync(location, 'utf-8');
18
+ }
19
+ catch (error) {
20
+ return error.code === 'ENOENT' ? null : undefined;
21
+ }
22
+ }
23
+ /**
24
+ * The commit surface's reader: it observes a staged diff, whose payloads already carry the
25
+ * pre their own observation saw, and it registers no shell axis — so no derivation ever asks
26
+ * for a pre-state here. Should one arrive, the working tree is not what this surface judges,
27
+ * and answering from it would compare the diff against the wrong baseline; `undefined` says
28
+ * so and fails that call closed.
29
+ */
30
+ export function unobservedPreStateReader() {
31
+ return undefined;
32
+ }
@@ -1,28 +1,37 @@
1
1
  /**
2
- * `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
2
+ * `scaffoldProject` — the project-side scaffold layer.
3
3
  *
4
4
  * The half of an installation every distribution path shares: the data config the judges
5
5
  * read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
6
6
  * agent is told to spawn a judge at all — and that lives one layer up (`initClaudeCode` for
7
- * the `init` path, a manifest for the plugin one). The split is what lets a second path
8
- * reuse this function unchanged instead of scaffolding a config a second time, so nothing
9
- * that registers anything belongs here.
7
+ * the `init` path). The split is what lets a second path reuse this function unchanged
8
+ * instead of scaffolding a config a second time, so nothing that registers anything belongs
9
+ * here.
10
10
  *
11
- * Nothing existing is ever overwritten (§5-d invariant 1): an artifact that is already there
12
- * is reported and left alone. The config existence check reads all three discovery
13
- * candidates rather than the canonical name alone — writing `polydeukes.config.yaml` next to
14
- * a project's `.yml` makes {@link loadConfig} throw on ambiguity, and the fail-closed session
15
- * surface then blocks every call, so the installer itself would be what stopped the project.
16
- * Existence is FILE PRESENCE, never parse success: reading a broken config as "absent" would
17
- * destroy the very file the consumer was midway through fixing, and fixing it is their job.
11
+ * Nothing existing is ever overwritten: an artifact that is already there is reported and
12
+ * left alone. The config existence check reads all three discovery candidates rather than
13
+ * the canonical name alone — writing `polydeukes.config.yaml` next to a project's `.yml`
14
+ * makes {@link loadConfig} throw on ambiguity, and the fail-closed session surface then
15
+ * blocks every call, so the installer itself would be what stopped the project. Existence is
16
+ * FILE PRESENCE, never parse success: reading a broken config as "absent" would destroy the
17
+ * very file the consumer was midway through fixing, and fixing it is their job.
18
18
  */
19
19
  /**
20
- * Per-artifact outcome, as `projectRoot`-relative paths — the bin prints it (§3-a stdout
21
- * contract). `created` names what this run wrote, `skipped` what it found and left alone; a
22
- * silent skip would leave the user unable to tell an idempotent no-op from a failed run.
20
+ * Per-artifact outcome, as `projectRoot`-relative paths — the bin prints it. `created` names
21
+ * what this run wrote, `skipped` what it found and left alone; a silent skip would leave the
22
+ * user unable to tell an idempotent no-op from a failed run.
23
23
  */
24
24
  export type ScaffoldReport = {
25
25
  created: string[];
26
26
  skipped: string[];
27
27
  };
28
+ /**
29
+ * Create the project-side artifacts of a Polydeukes installation in `projectRoot`, skipping
30
+ * whatever is already there.
31
+ *
32
+ * Throws when two or more config spellings coexist — that tree is already stopped, since
33
+ * {@link loadConfig} refuses an ambiguous discovery, and adding artifacts to it would wire a
34
+ * judge whose every call fails closed. The throw lands before any write, so a human deleting
35
+ * one config is all it takes to reopen the path.
36
+ */
28
37
  export declare function scaffoldProject(projectRoot: string): ScaffoldReport;
@@ -1,36 +1,36 @@
1
1
  /**
2
- * `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
2
+ * `scaffoldProject` — the project-side scaffold layer.
3
3
  *
4
4
  * The half of an installation every distribution path shares: the data config the judges
5
5
  * read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
6
6
  * agent is told to spawn a judge at all — and that lives one layer up (`initClaudeCode` for
7
- * the `init` path, a manifest for the plugin one). The split is what lets a second path
8
- * reuse this function unchanged instead of scaffolding a config a second time, so nothing
9
- * that registers anything belongs here.
7
+ * the `init` path). The split is what lets a second path reuse this function unchanged
8
+ * instead of scaffolding a config a second time, so nothing that registers anything belongs
9
+ * here.
10
10
  *
11
- * Nothing existing is ever overwritten (§5-d invariant 1): an artifact that is already there
12
- * is reported and left alone. The config existence check reads all three discovery
13
- * candidates rather than the canonical name alone — writing `polydeukes.config.yaml` next to
14
- * a project's `.yml` makes {@link loadConfig} throw on ambiguity, and the fail-closed session
15
- * surface then blocks every call, so the installer itself would be what stopped the project.
16
- * Existence is FILE PRESENCE, never parse success: reading a broken config as "absent" would
17
- * destroy the very file the consumer was midway through fixing, and fixing it is their job.
11
+ * Nothing existing is ever overwritten: an artifact that is already there is reported and
12
+ * left alone. The config existence check reads all three discovery candidates rather than
13
+ * the canonical name alone — writing `polydeukes.config.yaml` next to a project's `.yml`
14
+ * makes {@link loadConfig} throw on ambiguity, and the fail-closed session surface then
15
+ * blocks every call, so the installer itself would be what stopped the project. Existence is
16
+ * FILE PRESENCE, never parse success: reading a broken config as "absent" would destroy the
17
+ * very file the consumer was midway through fixing, and fixing it is their job.
18
18
  */
19
19
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { CONFIG_FILENAMES } from './load-config.js';
22
- /** `.gitignore` name and the telemetry directory entry it must carry (§3-a). */
22
+ /** `.gitignore` name and the telemetry directory entry it must carry. */
23
23
  const GITIGNORE = '.gitignore';
24
24
  const TELEMETRY_IGNORE_LINE = '.polydeukes/';
25
25
  const GITIGNORE_ENTRY = `# Polydeukes telemetry — local observation data, never committed.\n${TELEMETRY_IGNORE_LINE}\n`;
26
26
  /**
27
- * The generated config: the §3-d minimum protection set and the §3-e witness block, both
28
- * mandatory. Emitted as a literal template rather than serialized from an object because
29
- * the comments ARE the artifact — a consumer's first contact with the protection surface is
30
- * reading why each entry is on it.
27
+ * The generated config: the minimum protection set and the witness block, both mandatory.
28
+ * Emitted as a literal template rather than serialized from an object because the comments
29
+ * ARE the artifact — a consumer's first contact with the protection surface is reading why
30
+ * each entry is on it.
31
31
  *
32
32
  * {@link schemaDirective} prepends the `yaml-language-server` line when the schema is where
33
- * that line would name it (DIST-05 §3-b).
33
+ * that line would name it.
34
34
  */
35
35
  const GENERATED_CONFIG = `# Polydeukes protection policy — generated by \`pdks init claude-code\`.
36
36
  #
@@ -50,14 +50,18 @@ languages:
50
50
  # The protection list. A tool call whose proven target is one of these paths is blocked, and
51
51
  # so is a shell command that mentions one without a read-only head.
52
52
  #
53
- # .claude/hooks, .claude/settings.json — the gate definitions themselves. Editing them
54
- # does not evade a judgment, it removes the judgment; the session surface is the only
55
- # layer that can watch it happen.
53
+ # .claude/hooks, .claude/settings.json, .grok/hooks — the gate definitions themselves.
54
+ # Editing them does not evade a judgment, it removes the judgment; the session surface
55
+ # is the only layer that can watch it happen.
56
56
  #
57
57
  # A minimum. Add entries as you find you want them.
58
+ #
59
+ # This list is what blocks. Every \`disciplines:\` entry below lands at advise — a break is
60
+ # recorded and the call goes on — unless the entry itself says \`enforce: block\`.
58
61
  protectedPaths:
59
62
  - '.claude/hooks'
60
63
  - '.claude/settings.json'
64
+ - '.grok/hooks'
61
65
 
62
66
  # The time-boxed witness — the human valve on a blocked verdict. A human types this token so
63
67
  # it stands alone on a message's FIRST line, the window holds for ttlMinutes, then blocking
@@ -65,22 +69,74 @@ protectedPaths:
65
69
  # window is always recorded as \`witnessed\` and never silent, and no agent can open one for
66
70
  # itself. The token is not a secret: the defence is provenance, not confidentiality.
67
71
  #
68
- # Keep this block. Without it no block can be opened by anyone, and .claude/hooks is on the
69
- # list above — so the first block would freeze the project until a human edits these files
70
- # from their own terminal.
72
+ # Keep this block. Without it no block can be opened by anyone, and the hook directories
73
+ # are on the list above — so the first block would freeze the project until a human edits
74
+ # these files from their own terminal.
71
75
  witness:
72
76
  token: 'pdks witness'
73
77
  ttlMinutes: 10
78
+
79
+ # The disciplines you judge by, and the three rungs one climbs — shown as three entries so
80
+ # each rung is a line you can copy. Uncomment to start; ids must stay distinct.
81
+ #
82
+ # disciplines:
83
+ # # A draft: prose only, no predicate. Registered and read, never judged.
84
+ # - id: 'no-todo-in-shipped-code-draft'
85
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
86
+ # draft: true
87
+ #
88
+ # # Promoted to a judgment. Advise is the default — recorded as \`advised\`, never stops
89
+ # # the call — so this line is optional; it is written here to show the rung.
90
+ # #
91
+ # # The declaration keys each side's matched lines by the matched text and breaks on what
92
+ # # the edit ADDED, so occurrences already in the tree are forgiven.
93
+ # - id: 'no-todo-in-shipped-code'
94
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
95
+ # declare:
96
+ # mechanism: 'added-only'
97
+ # scope: { source: 'target.path', include: ['^src/'] }
98
+ # supply: { pre: 'empty', post: 'empty' }
99
+ # extract:
100
+ # before:
101
+ # - { op: 'source', of: 'pre' }
102
+ # - { op: 'lines' }
103
+ # - { op: 'keyByPattern', re: '(TODO)' }
104
+ # after:
105
+ # - { op: 'source', of: 'post' }
106
+ # - { op: 'lines' }
107
+ # - { op: 'keyByPattern', re: '(TODO)' }
108
+ # added:
109
+ # - { op: 'onlyIn', of: 'after', notIn: 'before' }
110
+ # relate:
111
+ # - id: 'nothing-added'
112
+ # relation: { op: 'empty', of: 'added' }
113
+ # message: 'adds {key}: {value}'
114
+ # enforce: advise
115
+ #
116
+ # # The promotion — block is your choice, never the default.
117
+ # - id: 'no-todo-in-shipped-code-blocking'
118
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
119
+ # declare:
120
+ # mechanism: 'added-only'
121
+ # scope: { source: 'target.path', include: ['^src/'] }
122
+ # supply: { pre: 'empty', post: 'empty' }
123
+ # extract:
124
+ # before:
125
+ # - { op: 'source', of: 'pre' }
126
+ # - { op: 'lines' }
127
+ # - { op: 'keyByPattern', re: '(TODO)' }
128
+ # after:
129
+ # - { op: 'source', of: 'post' }
130
+ # - { op: 'lines' }
131
+ # - { op: 'keyByPattern', re: '(TODO)' }
132
+ # added:
133
+ # - { op: 'onlyIn', of: 'after', notIn: 'before' }
134
+ # relate:
135
+ # - id: 'nothing-added'
136
+ # relation: { op: 'empty', of: 'added' }
137
+ # message: 'adds {key}: {value}'
138
+ # enforce: block
74
139
  `;
75
- /**
76
- * Create the project-side artifacts of a Polydeukes installation in `projectRoot` (§3-i),
77
- * skipping whatever is already there.
78
- *
79
- * Throws when two or more config spellings coexist (§3-a third disposition) — that tree is
80
- * already stopped, since {@link loadConfig} refuses an ambiguous discovery, and adding
81
- * artifacts to it would wire a judge whose every call fails closed. The throw lands before
82
- * any write, so a human deleting one config is all it takes to reopen the path.
83
- */
84
140
  /** The schema's path from a config sitting in `projectRoot`, as the directive spells it. */
85
141
  const SCHEMA_REL = 'node_modules/polydeukes/dist/schema/polydeukes.schema.json';
86
142
  /**
@@ -99,6 +155,15 @@ function schemaDirective(projectRoot) {
99
155
  ? `# yaml-language-server: $schema=${SCHEMA_REL}\n`
100
156
  : '';
101
157
  }
158
+ /**
159
+ * Create the project-side artifacts of a Polydeukes installation in `projectRoot`, skipping
160
+ * whatever is already there.
161
+ *
162
+ * Throws when two or more config spellings coexist — that tree is already stopped, since
163
+ * {@link loadConfig} refuses an ambiguous discovery, and adding artifacts to it would wire a
164
+ * judge whose every call fails closed. The throw lands before any write, so a human deleting
165
+ * one config is all it takes to reopen the path.
166
+ */
102
167
  export function scaffoldProject(projectRoot) {
103
168
  const report = { created: [], skipped: [] };
104
169
  const found = CONFIG_FILENAMES.filter((name) => existsSync(join(projectRoot, name)));