polydeukes 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +9 -3
- package/README.md +12 -4
- package/dist/baseline.d.ts +82 -0
- package/dist/baseline.js +166 -0
- package/dist/bin.d.ts +1 -1
- package/dist/bin.js +19 -2
- package/dist/claude-code-hook.d.ts +7 -5
- package/dist/claude-code-hook.js +69 -17
- package/dist/claude-code.d.ts +6 -0
- package/dist/claude-code.js +6 -0
- package/dist/covenant-check.d.ts +10 -17
- package/dist/covenant-check.js +44 -12
- package/dist/covenant-module.d.ts +2 -2
- package/dist/covenant-module.js +9 -1
- package/dist/docs/configuration.md +1 -1
- package/dist/docs/installation.md +41 -12
- package/dist/docs/reference/adapter-claude-code.md +6 -4
- package/dist/docs/reference/adapter-git.md +8 -6
- package/dist/docs/reference/configuration.md +222 -104
- package/dist/docs/reference/core.md +11 -5
- package/dist/docs/reference/covenant.md +31 -23
- package/dist/docs/reference/polydeukes.md +62 -34
- package/dist/docs/troubleshooting.md +36 -9
- package/dist/explain.d.ts +3 -5
- package/dist/explain.js +48 -47
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/init-claude-code.d.ts +5 -3
- package/dist/init-claude-code.js +76 -30
- package/dist/init-grok.d.ts +51 -0
- package/dist/init-grok.js +242 -0
- package/dist/load-config.d.ts +5 -1
- package/dist/load-config.js +2 -1
- package/dist/pre-state-reader.d.ts +22 -0
- package/dist/pre-state-reader.js +32 -0
- package/dist/scaffold-project.js +48 -8
- package/dist/schema/polydeukes.schema.json +38 -91
- package/package.json +7 -7
package/dist/init-claude-code.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Nothing existing is overwritten. The settings file in particular is merged, never
|
|
16
16
|
* replaced: a consumer's other PreToolUse registrations and permissions are live
|
|
17
|
-
* configuration, and replacing them would disarm every other tool they wired.
|
|
17
|
+
* configuration, and replacing them would disarm every other tool they wired. A grok JSON
|
|
18
|
+
* whose command still names the grok mjs is rewritten to this hook's command so the two
|
|
19
|
+
* installers do not leave two spawn strings.
|
|
18
20
|
*/
|
|
19
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
22
|
import { findPackageJSON } from 'node:module';
|
|
@@ -22,6 +24,7 @@ import { dirname, join } from 'node:path';
|
|
|
22
24
|
import { MUTATING_TOOLS, SHELL_TOOLS } from '@polydeukes/adapter-claude-code';
|
|
23
25
|
import { isPlainObject } from '@polydeukes/core';
|
|
24
26
|
import { TOPICS } from './docs-query.js';
|
|
27
|
+
import { retargetGrokHookCommandToClaude } from './init-grok.js';
|
|
25
28
|
import { CONFIG_FILENAMES } from './load-config.js';
|
|
26
29
|
import { scaffoldProject } from './scaffold-project.js';
|
|
27
30
|
/** The published entry point the generated hook loads the judge through. */
|
|
@@ -153,21 +156,20 @@ promises and classify each separately.
|
|
|
153
156
|
|
|
154
157
|
Ask these questions in order; the first yes decides.
|
|
155
158
|
|
|
156
|
-
| # | Question |
|
|
157
|
-
| --- | --- | --- |
|
|
158
|
-
| 1 | Is the promise about content newly ADDED to a file (a pattern that must not appear in new lines)? |
|
|
159
|
-
| 2 | Is it about a whole path that must not be modified or deleted (creating it once stays allowed)? |
|
|
160
|
-
| 3 | Is it about the shell command line itself, regardless of files? | command
|
|
161
|
-
| 4 | Does it require that something else was already done earlier in the session (a tool call that must precede this one)? |
|
|
162
|
-
| 5 | None of the above |
|
|
159
|
+
| # | Question | Entry key |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| 1 | Is the promise about content newly ADDED to a file (a pattern that must not appear in new lines)? | \`declare\` (mechanism \`added-only\`) |
|
|
162
|
+
| 2 | Is it about a whole path that must not be modified or deleted (creating it once stays allowed)? | \`declare\` (mechanism \`self-absolution-ban\`) |
|
|
163
|
+
| 3 | Is it about the shell command line itself, regardless of files? | \`declare\` (mechanism \`forbidden-command\`, reading the \`command\` source) |
|
|
164
|
+
| 4 | Does it require that something else was already done earlier in the session (a tool call that must precede this one)? | \`declare\` (mechanism \`precedent\`, reading a \`transcript\` source) |
|
|
165
|
+
| 5 | None of the above | \`draft: true\` (step 4b) |
|
|
163
166
|
|
|
164
|
-
|
|
165
|
-
That is usually what you want: a discipline adopted today should not indict
|
|
167
|
+
An \`added-only\` declaration forgives existing occurrences — only what the edit adds breaks
|
|
168
|
+
the promise. That is usually what you want: a discipline adopted today should not indict
|
|
169
|
+
yesterday's code.
|
|
166
170
|
|
|
167
|
-
|
|
168
|
-
belongs in the top-level \`protectedPaths:\` list — its own config block, never an entry
|
|
169
|
-
key. And a path that must never be CREATED is not expressible today: \`immutable\` allows
|
|
170
|
-
creation by design, so register that promise as a draft (step 4b).
|
|
171
|
+
One path-shaped promise takes no \`disciplines:\` entry at all: a path nobody may touch
|
|
172
|
+
belongs in the top-level \`protectedPaths:\` list — its own config block, never an entry key.
|
|
171
173
|
|
|
172
174
|
### 3. Check the observation boundary
|
|
173
175
|
|
|
@@ -198,7 +200,49 @@ languages:
|
|
|
198
200
|
disciplines:
|
|
199
201
|
- id: 'no-focused-tests'
|
|
200
202
|
why: 'a committed .only silently shrinks the suite to one test'
|
|
201
|
-
|
|
203
|
+
declare:
|
|
204
|
+
mechanism: 'added-only'
|
|
205
|
+
scope: { source: 'target.path', include: ['^src/'] }
|
|
206
|
+
supply: { pre: 'empty', post: 'empty' }
|
|
207
|
+
extract:
|
|
208
|
+
before:
|
|
209
|
+
- { op: 'source', of: 'pre' }
|
|
210
|
+
- { op: 'lines' }
|
|
211
|
+
- { op: 'keyByPattern', re: '(\\.only\\()' }
|
|
212
|
+
after:
|
|
213
|
+
- { op: 'source', of: 'post' }
|
|
214
|
+
- { op: 'lines' }
|
|
215
|
+
- { op: 'keyByPattern', re: '(\\.only\\()' }
|
|
216
|
+
added:
|
|
217
|
+
- { op: 'onlyIn', of: 'after', notIn: 'before' }
|
|
218
|
+
relate:
|
|
219
|
+
- id: 'nothing-added'
|
|
220
|
+
relation: { op: 'empty', of: 'added' }
|
|
221
|
+
message: 'adds {key}: {value}'
|
|
222
|
+
enforce: advise
|
|
223
|
+
\`\`\`
|
|
224
|
+
|
|
225
|
+
A command-line ban reads the fixed source \`command\` and scopes on it — the scope is part of
|
|
226
|
+
the mechanism's shape, so a \`forbidden-command\` entry without it is refused at load time:
|
|
227
|
+
|
|
228
|
+
\`\`\`yaml
|
|
229
|
+
languages:
|
|
230
|
+
placeholder:
|
|
231
|
+
productionGlob: 'src/**'
|
|
232
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
233
|
+
disciplines:
|
|
234
|
+
- id: 'no-force-push'
|
|
235
|
+
why: 'a force push rewrites history nobody reviewed'
|
|
236
|
+
declare:
|
|
237
|
+
mechanism: 'forbidden-command'
|
|
238
|
+
scope: { source: 'command' }
|
|
239
|
+
extract:
|
|
240
|
+
hits:
|
|
241
|
+
- { op: 'source', of: 'command' }
|
|
242
|
+
- { op: 'lines' }
|
|
243
|
+
- { op: 'matches', re: 'git push\\\\b.*--force(?![\\\\w-])' }
|
|
244
|
+
relate:
|
|
245
|
+
- { id: 'no-force', relation: { op: 'empty', of: 'hits' }, message: '{value}' }
|
|
202
246
|
enforce: advise
|
|
203
247
|
\`\`\`
|
|
204
248
|
|
|
@@ -208,14 +252,15 @@ authoring traps, each measured on a live config:
|
|
|
208
252
|
|
|
209
253
|
- **A pattern answers a syntactic question only.** "Is this string a forbidden word" is
|
|
210
254
|
syntax; "is this a new dependency version" is meaning, and a regex leaks both ways on a
|
|
211
|
-
semantic question. When the question is semantic, narrow
|
|
212
|
-
match IS a break
|
|
213
|
-
|
|
214
|
-
- **\`^\`
|
|
215
|
-
|
|
216
|
-
|
|
255
|
+
semantic question. When the question is semantic, narrow the declaration's own \`scope\`
|
|
256
|
+
block to the files where any match IS a break, or accept "editing this file at all" as
|
|
257
|
+
the trigger.
|
|
258
|
+
- **\`^\` means what the preceding step left.** After a \`lines\` step a declaration's
|
|
259
|
+
pattern sees one line at a time, so \`^\` anchors to that line; over an unsplit source it
|
|
260
|
+
anchors to the whole text and matches the first line only. A ban over the command line
|
|
261
|
+
puts \`lines\` before its \`matches\` for exactly that reason.
|
|
217
262
|
- **Author both directions.** Before registering, write down one string the pattern must
|
|
218
|
-
match and one nearby string it must not (\`
|
|
263
|
+
match and one nearby string it must not (\`only(\` vs \`only_helper(\`, a flag vs its
|
|
219
264
|
substring). A pattern checked in only the breaking direction over-fires in review-proof
|
|
220
265
|
ways.
|
|
221
266
|
|
|
@@ -258,18 +303,18 @@ disciplines:
|
|
|
258
303
|
|
|
259
304
|
### 5. Prove it fires, then close
|
|
260
305
|
|
|
261
|
-
Run \`pdks explain\` and confirm the new entry is listed (a judged entry with its
|
|
262
|
-
surfaces; a draft as unpromoted).
|
|
306
|
+
Run \`pdks explain\` and confirm the new entry is listed (a judged entry with its mechanism
|
|
307
|
+
and surfaces; a draft as unpromoted).
|
|
263
308
|
|
|
264
309
|
For a judged entry, registration is not the finish — a pattern that never fires protects
|
|
265
|
-
nothing while looking installed. Fire it once for real, with the proof run
|
|
266
|
-
actually reach:
|
|
310
|
+
nothing while looking installed. Fire it once for real, with the proof run the declaration's
|
|
311
|
+
own mechanism can actually reach:
|
|
267
312
|
|
|
268
|
-
|
|
|
313
|
+
| Mechanism | Break it once | The entry's id shows up in |
|
|
269
314
|
| --- | --- | --- |
|
|
270
|
-
|
|
|
271
|
-
| \`
|
|
272
|
-
| \`
|
|
315
|
+
| a file-reading one (\`added-only\`, \`naming\`, …) | one scratch edit matching the must-match direction | \`pdks covenant check --worktree\` output — the exit stays 0 at advise, the id is the proof |
|
|
316
|
+
| \`forbidden-command\` | run one harmless command matching the pattern | the telemetry log tail — at advise the call proceeds and its row records the id |
|
|
317
|
+
| \`precedent\` | one in-scope edit made without the required precedent | the telemetry log tail — a declaration reading the session judges on the session surface only (the commit surface has none, so its \`supply\` policy records it \`skipped\`) |
|
|
273
318
|
|
|
274
319
|
Then undo the scratch break, repeat the same run, and confirm silence on the
|
|
275
320
|
must-NOT-match direction. Close by telling the user which rung the entry landed on and
|
|
@@ -419,5 +464,6 @@ export function initClaudeCode(spec) {
|
|
|
419
464
|
// a failure costs least.
|
|
420
465
|
writeIfAbsent(spec.projectRoot, DISCOVERY_RELATIVE, GENERATED_DISCOVERY, report);
|
|
421
466
|
writeIfAbsent(spec.projectRoot, SKILL_RELATIVE, GENERATED_SKILL, report);
|
|
467
|
+
retargetGrokHookCommandToClaude(spec.projectRoot);
|
|
422
468
|
return report;
|
|
423
469
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { type ScaffoldReport } from './scaffold-project.ts';
|
|
20
|
+
/** `initGrok` input — the target tree and the preflight seam. */
|
|
21
|
+
export type InitGrokSpec = {
|
|
22
|
+
/** Project root to install into — every write below is relative to it. */
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
/**
|
|
25
|
+
* Preflight seam: throws when the package cannot be resolved from the given root.
|
|
26
|
+
* ABSENT uses the real resolution, anchored at that root and nowhere else — anchoring it
|
|
27
|
+
* at the installer's own module would answer for the installer's install graph rather
|
|
28
|
+
* than the target project's, which is precisely the case that must fail.
|
|
29
|
+
*/
|
|
30
|
+
resolvePolydeukes?: (projectRoot: string) => void;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Rewrite the grok JSON `command` from the grok-mjs string to the Claude-hook string.
|
|
34
|
+
*
|
|
35
|
+
* The Claude installer calls this after writing its delegator; this installer calls it on
|
|
36
|
+
* re-run when that file is already on disk. Only the installer-generated grok-mjs command
|
|
37
|
+
* is rewritten; any other string is the consumer's spawn target and the file is not touched.
|
|
38
|
+
* Every entry naming the Claude-hook command — rewritten now or by an earlier install — takes
|
|
39
|
+
* the matcher the Claude settings file registered that command under, so the host sees one
|
|
40
|
+
* pair rather than two and a re-run converges. Parse failure leaves the file as it was —
|
|
41
|
+
* existence is presence, not parse success.
|
|
42
|
+
*/
|
|
43
|
+
export declare function retargetGrokHookCommandToClaude(projectRoot: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* Install the Grok session surface into `spec.projectRoot`, skipping whatever is already
|
|
46
|
+
* there and reporting both halves per artifact.
|
|
47
|
+
*
|
|
48
|
+
* Throws before any write when the package cannot be resolved from that root — that leaves
|
|
49
|
+
* zero files. Translating a throw into exit 2 with the install command is the bin's job.
|
|
50
|
+
*/
|
|
51
|
+
export declare function initGrok(spec: InitGrokSpec): ScaffoldReport;
|
|
@@ -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
|
+
}
|
package/dist/load-config.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ import type { ResolvedConfig } from '@polydeukes/core';
|
|
|
16
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
|
+
/** {@link loadConfig} input — the directory the config is discovered in. */
|
|
20
|
+
export type LoadConfigSpec = {
|
|
21
|
+
rootDir: string;
|
|
22
|
+
};
|
|
19
23
|
/** `LoadedConfig` — the loader's return value. */
|
|
20
24
|
export type LoadedConfig = {
|
|
21
25
|
/** defineConfig() resolution — protectedPaths already includes configPath */
|
|
@@ -38,4 +42,4 @@ export type LoadedConfig = {
|
|
|
38
42
|
* `config.protectedPaths` unless already present — the config file itself joins the
|
|
39
43
|
* protection surface, guaranteed here so no assembler has to remember.
|
|
40
44
|
*/
|
|
41
|
-
export declare function loadConfig(
|
|
45
|
+
export declare function loadConfig(spec: LoadConfigSpec): LoadedConfig;
|
package/dist/load-config.js
CHANGED
|
@@ -38,7 +38,8 @@ export const CONFIG_FILENAMES = [
|
|
|
38
38
|
* `config.protectedPaths` unless already present — the config file itself joins the
|
|
39
39
|
* protection surface, guaranteed here so no assembler has to remember.
|
|
40
40
|
*/
|
|
41
|
-
export function loadConfig(
|
|
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
|
+
}
|
package/dist/scaffold-project.js
CHANGED
|
@@ -50,9 +50,9 @@ 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.
|
|
54
|
-
# does not evade a judgment, it removes the judgment; the session surface
|
|
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
58
|
#
|
|
@@ -61,6 +61,7 @@ languages:
|
|
|
61
61
|
protectedPaths:
|
|
62
62
|
- '.claude/hooks'
|
|
63
63
|
- '.claude/settings.json'
|
|
64
|
+
- '.grok/hooks'
|
|
64
65
|
|
|
65
66
|
# The time-boxed witness — the human valve on a blocked verdict. A human types this token so
|
|
66
67
|
# it stands alone on a message's FIRST line, the window holds for ttlMinutes, then blocking
|
|
@@ -68,9 +69,9 @@ protectedPaths:
|
|
|
68
69
|
# window is always recorded as \`witnessed\` and never silent, and no agent can open one for
|
|
69
70
|
# itself. The token is not a secret: the defence is provenance, not confidentiality.
|
|
70
71
|
#
|
|
71
|
-
# Keep this block. Without it no block can be opened by anyone, and
|
|
72
|
-
# list above — so the first block would freeze the project until a human edits
|
|
73
|
-
# 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.
|
|
74
75
|
witness:
|
|
75
76
|
token: 'pdks witness'
|
|
76
77
|
ttlMinutes: 10
|
|
@@ -86,15 +87,54 @@ witness:
|
|
|
86
87
|
#
|
|
87
88
|
# # Promoted to a judgment. Advise is the default — recorded as \`advised\`, never stops
|
|
88
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.
|
|
89
93
|
# - id: 'no-todo-in-shipped-code'
|
|
90
94
|
# why: 'a TODO nobody owns is a decision deferred out of sight'
|
|
91
|
-
#
|
|
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}'
|
|
92
114
|
# enforce: advise
|
|
93
115
|
#
|
|
94
116
|
# # The promotion — block is your choice, never the default.
|
|
95
117
|
# - id: 'no-todo-in-shipped-code-blocking'
|
|
96
118
|
# why: 'a TODO nobody owns is a decision deferred out of sight'
|
|
97
|
-
#
|
|
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}'
|
|
98
138
|
# enforce: block
|
|
99
139
|
`;
|
|
100
140
|
/** The schema's path from a config sitting in `projectRoot`, as the directive spells it. */
|