polydeukes 0.0.1 → 0.4.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/LICENSE +21 -0
- package/README.ko.md +73 -0
- package/README.md +67 -12
- package/dist/bin.d.ts +15 -0
- package/dist/bin.js +138 -0
- package/dist/claude-code-hook.d.ts +56 -0
- package/dist/claude-code-hook.js +360 -0
- package/dist/covenant-check.d.ts +58 -0
- package/dist/covenant-check.js +262 -0
- package/dist/docs/configuration.md +95 -0
- package/dist/docs/installation.md +211 -0
- package/dist/docs/reference/adapter-claude-code.md +82 -0
- package/dist/docs/reference/adapter-git.md +87 -0
- package/dist/docs/reference/configuration.md +294 -0
- package/dist/docs/reference/core.md +111 -0
- package/dist/docs/reference/covenant.md +108 -0
- package/dist/docs/reference/polydeukes.md +215 -0
- package/dist/docs/troubleshooting.md +160 -0
- package/dist/docs-query.d.ts +46 -0
- package/dist/docs-query.js +138 -0
- package/dist/index.d.ts +23 -4
- package/dist/index.js +22 -4
- package/dist/init-claude-code.d.ts +39 -0
- package/dist/init-claude-code.js +255 -0
- package/dist/load-config.d.ts +43 -0
- package/dist/load-config.js +90 -0
- package/dist/scaffold-project.d.ts +28 -0
- package/dist/scaffold-project.js +137 -0
- package/dist/schema/polydeukes.schema.json +203 -0
- package/package.json +31 -11
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `initClaudeCode` — the session-surface installer (DIST-02 §3-a/§3-b/§3-g).
|
|
3
|
+
*
|
|
4
|
+
* One command wires a project into the session surface: prove the package resolves, run the
|
|
5
|
+
* shared project-side scaffold ({@link scaffoldProject}), then add what this distribution
|
|
6
|
+
* path owns — the delegator hook file, its `.claude/settings.json` registration, and the
|
|
7
|
+
* discipline file that tells an agent the docs query exists (DOCS-02 §3-e).
|
|
8
|
+
*
|
|
9
|
+
* Preflight comes first and nothing is written before it clears (§5-d invariant 2). A
|
|
10
|
+
* generated hook whose import can never resolve blocks every call through its own
|
|
11
|
+
* fail-closed catch, and a tree that also has no config and no valve to open cannot be
|
|
12
|
+
* edited back into shape from inside the session — the brick §3-g exists to prevent.
|
|
13
|
+
*
|
|
14
|
+
* Nothing existing is overwritten (§5-d invariant 1). The settings file in particular is
|
|
15
|
+
* merged, never replaced: a consumer's other PreToolUse registrations and permissions are
|
|
16
|
+
* live configuration, and replacing them would disarm every other tool they wired.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { findPackageJSON } from 'node:module';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { MUTATING_TOOLS, SHELL_TOOLS } from '@polydeukes/adapter-claude-code';
|
|
22
|
+
import { isPlainObject } from '@polydeukes/core';
|
|
23
|
+
import { TOPICS } from './docs-query.js';
|
|
24
|
+
import { CONFIG_FILENAMES } from './load-config.js';
|
|
25
|
+
import { scaffoldProject } from './scaffold-project.js';
|
|
26
|
+
/** The published entry point the generated hook loads the judge through (§3-c). */
|
|
27
|
+
const HOOK_SPECIFIER = 'polydeukes/claude-code';
|
|
28
|
+
/** The registration artifacts, as `projectRoot`-relative paths (the report vocabulary). */
|
|
29
|
+
const HOOK_RELATIVE = '.claude/hooks/covenant-pretooluse.mjs';
|
|
30
|
+
const SETTINGS_RELATIVE = '.claude/settings.json';
|
|
31
|
+
const DISCOVERY_RELATIVE = '.claude/rules/polydeukes.md';
|
|
32
|
+
/**
|
|
33
|
+
* The command the host spawns, and the string our registration is recognized by: the same
|
|
34
|
+
* command already present means already registered (§3-a). A registration keyed on anything
|
|
35
|
+
* else would be re-added on every run, and the host would then spawn the judge twice per
|
|
36
|
+
* call — every verdict and every telemetry row doubled.
|
|
37
|
+
*/
|
|
38
|
+
const HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR"/${HOOK_RELATIVE}`;
|
|
39
|
+
/** Which calls reach the judge — the adapter's own vocabulary, never a copy of it. */
|
|
40
|
+
const HOOK_MATCHER = [...MUTATING_TOOLS, ...SHELL_TOOLS].join('|');
|
|
41
|
+
/**
|
|
42
|
+
* The generated hook (§3-b) — a copy of this repository's own delegator with its dogfooding
|
|
43
|
+
* narrative removed. It carries no assembly at all, so upgrading the package upgrades the
|
|
44
|
+
* judge without regenerating this file.
|
|
45
|
+
*/
|
|
46
|
+
const GENERATED_HOOK = `#!/usr/bin/env node
|
|
47
|
+
/**
|
|
48
|
+
* Polydeukes PreToolUse covenant hook — generated by \`pdks init claude-code\`.
|
|
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
|
+
process.exit(exitCode);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(\`covenant hook failed closed: \${error?.message ?? error}\`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
/** What a session is about to do, per topic — the correspondence §3-e asks the file to carry. */
|
|
81
|
+
const DOCS_TOPIC_PURPOSE = {
|
|
82
|
+
install: 'install Polydeukes, or wire another surface into this project',
|
|
83
|
+
config: 'edit `polydeukes.config.*` — every key and what reads it',
|
|
84
|
+
discipline: 'add or change a `disciplines` entry',
|
|
85
|
+
covenant: 'explain a verdict, or why a surface failed closed',
|
|
86
|
+
witness: 'open a blocked call in person',
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The generated discipline file (DOCS-02 §3-e) — the discovery path that gets the query
|
|
90
|
+
* surface called. A query an agent never learns about is a query that does not exist, and
|
|
91
|
+
* the alternative place to say so is the consumer's own resident instructions, which are
|
|
92
|
+
* theirs to write. One scoped file costs nothing while it waits: `paths` frontmatter keeps
|
|
93
|
+
* it out of context until a Polydeukes path is in play.
|
|
94
|
+
*
|
|
95
|
+
* Both the command forms and the topic names come from the shipped surface itself — a file
|
|
96
|
+
* naming a query that exits 2 fails the agent once, and it never calls the command again.
|
|
97
|
+
*/
|
|
98
|
+
const GENERATED_DISCOVERY = `---
|
|
99
|
+
paths:
|
|
100
|
+
${CONFIG_FILENAMES.map((name) => ` - "${name}"`).join('\n')}
|
|
101
|
+
- ".claude/**"
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
# Polydeukes — query the installed docs
|
|
105
|
+
|
|
106
|
+
This project is judged by Polydeukes, and the matching documentation ships inside the
|
|
107
|
+
installed package. \`pdks docs\` answers offline, from the same version that does the
|
|
108
|
+
judging; a web search answers from whichever release it indexed.
|
|
109
|
+
|
|
110
|
+
Run \`pdks docs\` for the topic list, \`pdks docs <topic>\` for one section.
|
|
111
|
+
|
|
112
|
+
A local install puts the bin in \`node_modules/.bin\`, which a plain shell does not have on
|
|
113
|
+
PATH. If \`pdks\` is not found, run \`./node_modules/.bin/pdks docs <topic>\` — or your package
|
|
114
|
+
manager's exec form — from the project root.
|
|
115
|
+
|
|
116
|
+
| Before you | Run |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
${TOPICS.map((topic) => `| ${DOCS_TOPIC_PURPOSE[topic]} | \`pdks docs ${topic}\` |`).join('\n')}
|
|
119
|
+
`;
|
|
120
|
+
/**
|
|
121
|
+
* The default preflight: is `polydeukes` installed where `projectRoot` can reach it?
|
|
122
|
+
*
|
|
123
|
+
* ESM resolution specifically, because that is what the generated hook's `await import(...)`
|
|
124
|
+
* runs; the CJS alternatives were measured disagreeing in both directions (DIST-02 §5-e,
|
|
125
|
+
* which also carries the standing risk that `findPackageJSON` is experimental in Node 24).
|
|
126
|
+
*/
|
|
127
|
+
function resolveFromProjectRoot(projectRoot) {
|
|
128
|
+
// Absence throws here rather than returning undefined (Node 24.18); the branch guards the
|
|
129
|
+
// documented `string | undefined` return.
|
|
130
|
+
const manifestPath = findPackageJSON('polydeukes', join(projectRoot, 'package.json'));
|
|
131
|
+
if (manifestPath === undefined) {
|
|
132
|
+
throw new Error('polydeukes is not installed where this project can reach it');
|
|
133
|
+
}
|
|
134
|
+
// Locating the package is not the question — `findPackageJSON` does not apply its exports
|
|
135
|
+
// map, so a version predating the session subpath, or one whose dist was never built,
|
|
136
|
+
// passes a bare-name check while the generated hook fails on every call. That tree cannot
|
|
137
|
+
// be reopened with the witness token either, because an assembly crash lands before any
|
|
138
|
+
// verdict (PR #48 review).
|
|
139
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
140
|
+
const subpath = isPlainObject(manifest) && isPlainObject(manifest.exports)
|
|
141
|
+
? manifest.exports[`./${HOOK_SPECIFIER.split('/')[1]}`]
|
|
142
|
+
: undefined;
|
|
143
|
+
const target = isPlainObject(subpath) ? subpath.import : undefined;
|
|
144
|
+
if (typeof target !== 'string' || !existsSync(join(dirname(manifestPath), target))) {
|
|
145
|
+
throw new Error(`the installed polydeukes does not expose '${HOOK_SPECIFIER}' — update or rebuild it`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Is our PreToolUse command already in this settings object? Asked twice — before the merge
|
|
150
|
+
* to stay idempotent, and after the write to prove the file took it.
|
|
151
|
+
*/
|
|
152
|
+
function carriesRegistration(settings) {
|
|
153
|
+
return (settings.hooks?.PreToolUse ?? []).some((entry) => (entry?.hooks ?? []).some((hook) => hook?.command === HOOK_COMMAND));
|
|
154
|
+
}
|
|
155
|
+
/** Write one generated artifact unless it is already there, recording which happened. */
|
|
156
|
+
function writeIfAbsent(projectRoot, relative, contents, report) {
|
|
157
|
+
const path = join(projectRoot, relative);
|
|
158
|
+
if (existsSync(path)) {
|
|
159
|
+
report.skipped.push(relative);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
163
|
+
writeFileSync(path, contents);
|
|
164
|
+
report.created.push(relative);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Read the settings file, or an empty one when there is none yet. Belongs to preflight
|
|
168
|
+
* rather than to the merge: parsing it later would leave a wired-but-unregistered tree —
|
|
169
|
+
* delegator on disk, host never told to spawn it, every call unjudged with no telemetry row.
|
|
170
|
+
*/
|
|
171
|
+
function readSettings(projectRoot) {
|
|
172
|
+
const settingsPath = join(projectRoot, SETTINGS_RELATIVE);
|
|
173
|
+
if (!existsSync(settingsPath)) {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw new Error(`cannot parse ${SETTINGS_RELATIVE} in ${projectRoot} — fix it and re-run ` +
|
|
182
|
+
`(${error instanceof Error ? error.message : String(error)})`);
|
|
183
|
+
}
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Add the PreToolUse registration to `.claude/settings.json`, keeping everything else the
|
|
188
|
+
* file carries — other registrations, other hook events, and unrelated keys alike. A
|
|
189
|
+
* settings file with no `hooks` key at all is the commonest consumer state (it exists for
|
|
190
|
+
* permissions alone), so the nesting is created here rather than assumed.
|
|
191
|
+
*/
|
|
192
|
+
function mergeSettings(projectRoot, settings, report) {
|
|
193
|
+
const settingsPath = join(projectRoot, SETTINGS_RELATIVE);
|
|
194
|
+
const preToolUse = settings.hooks?.PreToolUse ?? [];
|
|
195
|
+
if (carriesRegistration(settings)) {
|
|
196
|
+
// Not rewriting is the point: a re-serialization would rewrite a consumer's formatting
|
|
197
|
+
// on every run, which is an overwrite by another name.
|
|
198
|
+
report.skipped.push(SETTINGS_RELATIVE);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
settings.hooks = {
|
|
202
|
+
...settings.hooks,
|
|
203
|
+
PreToolUse: [
|
|
204
|
+
...preToolUse,
|
|
205
|
+
{ matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_COMMAND }] },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
209
|
+
// Read the registration back rather than assuming the write carried it. The one outcome
|
|
210
|
+
// this installer must never produce is a successful-looking run whose judge never spawns,
|
|
211
|
+
// and the merge can drop the entry without failing — a settings file whose root is an
|
|
212
|
+
// array takes the assignment as a non-index property and `JSON.stringify` discards it
|
|
213
|
+
// (PR #48 review). Checking the file instead of the shapes that reach it keeps the
|
|
214
|
+
// question finite: one code path, asked after every write, whatever arrived.
|
|
215
|
+
if (!carriesRegistration(JSON.parse(readFileSync(settingsPath, 'utf-8')))) {
|
|
216
|
+
throw new Error(`${SETTINGS_RELATIVE} in ${projectRoot} did not take the PreToolUse registration — ` +
|
|
217
|
+
'the judge would never be spawned. Fix that file and re-run');
|
|
218
|
+
}
|
|
219
|
+
report.created.push(SETTINGS_RELATIVE);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Install the session surface into `spec.projectRoot` (DIST-02 §3-a), skipping whatever is
|
|
223
|
+
* already there and reporting both halves per artifact.
|
|
224
|
+
*
|
|
225
|
+
* Throws before any write when the package cannot be resolved from that root (§3-g) or when
|
|
226
|
+
* two config spellings already coexist there (§3-a third disposition) — both leave zero
|
|
227
|
+
* files. Translating a throw into exit 2 with the install command is the bin's job.
|
|
228
|
+
*/
|
|
229
|
+
export function initClaudeCode(spec) {
|
|
230
|
+
const resolvePolydeukes = spec.resolvePolydeukes ?? resolveFromProjectRoot;
|
|
231
|
+
try {
|
|
232
|
+
resolvePolydeukes(spec.projectRoot);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// The message names the package because the user's next action is installing it — the
|
|
236
|
+
// seam's own message cannot be relied on to say so. The original is carried through
|
|
237
|
+
// rather than discarded: "not exposed" and "not installed" need different actions, and
|
|
238
|
+
// an experimental resolver can fail for reasons that are neither (PR #48 review).
|
|
239
|
+
throw new Error(`cannot use 'polydeukes' from ${spec.projectRoot} — install or update it there first ` +
|
|
240
|
+
"(e.g. 'npm install --save-dev polydeukes'), then run this command again: " +
|
|
241
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
// Every read that can fail is settled before the first write (§5-d invariant 2).
|
|
244
|
+
const settings = readSettings(spec.projectRoot);
|
|
245
|
+
const report = scaffoldProject(spec.projectRoot);
|
|
246
|
+
writeIfAbsent(spec.projectRoot, HOOK_RELATIVE, GENERATED_HOOK, report);
|
|
247
|
+
mergeSettings(spec.projectRoot, settings, report);
|
|
248
|
+
// Written last, after the registration the hook needs to ever be spawned. Every write
|
|
249
|
+
// between the hook file and that registration widens the window where a throw leaves a
|
|
250
|
+
// delegator nothing invokes — a tree that looks installed and is judged by nothing. This
|
|
251
|
+
// artifact is the one whose absence costs only discoverability, so it goes where a
|
|
252
|
+
// failure costs least.
|
|
253
|
+
writeIfAbsent(spec.projectRoot, DISCOVERY_RELATIVE, GENERATED_DISCOVERY, report);
|
|
254
|
+
return report;
|
|
255
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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.
|
|
4
|
+
*
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
import type { ResolvedConfig } from '@polydeukes/core';
|
|
13
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
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
|
+
*/
|
|
22
|
+
export type LoadedConfig = {
|
|
23
|
+
/** defineConfig() resolution — protectedPaths already includes configPath */
|
|
24
|
+
config: ResolvedConfig;
|
|
25
|
+
/** rootDir-relative path of the discovered config file */
|
|
26
|
+
configPath: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
|
|
30
|
+
*
|
|
31
|
+
* Discovery looks at exactly the three candidate filenames directly under `rootDir`
|
|
32
|
+
* (no upward walk). Every failure branch throws — silent defaults are forbidden:
|
|
33
|
+
* zero files found (message names all three candidates), two or more found (message
|
|
34
|
+
* names the collisions), a parse error or unresolved custom tag (safe core schema —
|
|
35
|
+
* config data is never executable; every problem the parser found is enumerated in the
|
|
36
|
+
* one message), or a `ConfigValidationError` from core `defineConfig()` (re-thrown with
|
|
37
|
+
* file-path context, keeping the error type).
|
|
38
|
+
*
|
|
39
|
+
* Before returning, the discovered `configPath` is appended to
|
|
40
|
+
* `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.
|
|
42
|
+
*/
|
|
43
|
+
export declare function loadConfig(rootDir: string): LoadedConfig;
|
|
@@ -0,0 +1,90 @@
|
|
|
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.
|
|
4
|
+
*
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { ConfigValidationError, defineConfig, isPlainObject } from '@polydeukes/core';
|
|
15
|
+
import { parseDocument } from 'yaml';
|
|
16
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
export const CONFIG_FILENAMES = [
|
|
22
|
+
'polydeukes.config.yaml',
|
|
23
|
+
'polydeukes.config.yml',
|
|
24
|
+
'polydeukes.config.json',
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
|
|
28
|
+
*
|
|
29
|
+
* Discovery looks at exactly the three candidate filenames directly under `rootDir`
|
|
30
|
+
* (no upward walk). Every failure branch throws — silent defaults are forbidden:
|
|
31
|
+
* zero files found (message names all three candidates), two or more found (message
|
|
32
|
+
* names the collisions), a parse error or unresolved custom tag (safe core schema —
|
|
33
|
+
* config data is never executable; every problem the parser found is enumerated in the
|
|
34
|
+
* one message), or a `ConfigValidationError` from core `defineConfig()` (re-thrown with
|
|
35
|
+
* file-path context, keeping the error type).
|
|
36
|
+
*
|
|
37
|
+
* Before returning, the discovered `configPath` is appended to
|
|
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.
|
|
40
|
+
*/
|
|
41
|
+
export function loadConfig(rootDir) {
|
|
42
|
+
const found = CONFIG_FILENAMES.filter((name) => existsSync(join(rootDir, name)));
|
|
43
|
+
if (found.length === 0) {
|
|
44
|
+
throw new Error(`no Polydeukes config found in ${rootDir} — expected one of: ${CONFIG_FILENAMES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
if (found.length > 1) {
|
|
47
|
+
throw new Error(`ambiguous Polydeukes config in ${rootDir} — found ${found.join(' and ')}; keep exactly one`);
|
|
48
|
+
}
|
|
49
|
+
const configPath = found[0];
|
|
50
|
+
const source = readFileSync(join(rootDir, configPath), 'utf-8');
|
|
51
|
+
// Default core schema — custom tags stay unresolved and surface as errors or
|
|
52
|
+
// warnings depending on version; both escalate to a throw (config-as-data:
|
|
53
|
+
// uncomputable, so it cannot lie).
|
|
54
|
+
const document = parseDocument(source);
|
|
55
|
+
const problems = [...document.errors, ...document.warnings];
|
|
56
|
+
if (problems.length > 0) {
|
|
57
|
+
// Every problem in one message: reporting only the first costs one fix-rerun loop
|
|
58
|
+
// per hidden problem. Each parser message already carries its own position; a lone
|
|
59
|
+
// problem keeps the direct message shape.
|
|
60
|
+
const detail = problems.length === 1
|
|
61
|
+
? problems[0].message
|
|
62
|
+
: `${problems.length} problems\n${problems.map((problem) => ` - ${problem.message}`).join('\n')}`;
|
|
63
|
+
throw new Error(`failed to parse ${configPath}: ${detail}`);
|
|
64
|
+
}
|
|
65
|
+
const parsed = document.toJS();
|
|
66
|
+
// Strip the IDE `$schema` reference before delegating — the loader owns no
|
|
67
|
+
// structural validation beyond this key removal.
|
|
68
|
+
let input = parsed;
|
|
69
|
+
if (isPlainObject(parsed)) {
|
|
70
|
+
const { $schema: _schema, ...rest } = parsed;
|
|
71
|
+
input = rest;
|
|
72
|
+
}
|
|
73
|
+
let config;
|
|
74
|
+
try {
|
|
75
|
+
config = defineConfig(input);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof ConfigValidationError) {
|
|
79
|
+
throw new ConfigValidationError(`invalid config in ${configPath}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
// Self-protection attach (idempotent) — the discovered config file is part of
|
|
84
|
+
// the protection surface.
|
|
85
|
+
const protectedPaths = config.protectedPaths ?? [];
|
|
86
|
+
if (!protectedPaths.includes(configPath)) {
|
|
87
|
+
config = { ...config, protectedPaths: [...protectedPaths, configPath] };
|
|
88
|
+
}
|
|
89
|
+
return { config, configPath };
|
|
90
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
|
|
3
|
+
*
|
|
4
|
+
* The half of an installation every distribution path shares: the data config the judges
|
|
5
|
+
* read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
|
|
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.
|
|
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.
|
|
18
|
+
*/
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
export type ScaffoldReport = {
|
|
25
|
+
created: string[];
|
|
26
|
+
skipped: string[];
|
|
27
|
+
};
|
|
28
|
+
export declare function scaffoldProject(projectRoot: string): ScaffoldReport;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
|
|
3
|
+
*
|
|
4
|
+
* The half of an installation every distribution path shares: the data config the judges
|
|
5
|
+
* read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
|
|
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.
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { CONFIG_FILENAMES } from './load-config.js';
|
|
22
|
+
/** `.gitignore` name and the telemetry directory entry it must carry (§3-a). */
|
|
23
|
+
const GITIGNORE = '.gitignore';
|
|
24
|
+
const TELEMETRY_IGNORE_LINE = '.polydeukes/';
|
|
25
|
+
const GITIGNORE_ENTRY = `# Polydeukes telemetry — local observation data, never committed.\n${TELEMETRY_IGNORE_LINE}\n`;
|
|
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.
|
|
31
|
+
*
|
|
32
|
+
* {@link schemaDirective} prepends the `yaml-language-server` line when the schema is where
|
|
33
|
+
* that line would name it (DIST-05 §3-b).
|
|
34
|
+
*/
|
|
35
|
+
const GENERATED_CONFIG = `# Polydeukes protection policy — generated by \`pdks init claude-code\`.
|
|
36
|
+
#
|
|
37
|
+
# This file is data, never code. The judges read it, every verdict traces back to an entry
|
|
38
|
+
# below, and editing it is how you change what is judged.
|
|
39
|
+
|
|
40
|
+
# The language axis, the schema's one required block. \`init\` cannot know yours, so this is
|
|
41
|
+
# a placeholder: rename the key, point productionGlob at your own sources, and put your own
|
|
42
|
+
# verification command in testCmd (every literal {scope} is substituted at resolve time).
|
|
43
|
+
# No judgment path reads these values yet, so the placeholder cannot produce a wrong verdict
|
|
44
|
+
# while it waits to be edited.
|
|
45
|
+
languages:
|
|
46
|
+
placeholder:
|
|
47
|
+
productionGlob: 'src/**'
|
|
48
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
49
|
+
|
|
50
|
+
# The protection list. A tool call whose proven target is one of these paths is blocked, and
|
|
51
|
+
# so is a shell command that mentions one without a read-only head.
|
|
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.
|
|
56
|
+
#
|
|
57
|
+
# A minimum. Add entries as you find you want them.
|
|
58
|
+
protectedPaths:
|
|
59
|
+
- '.claude/hooks'
|
|
60
|
+
- '.claude/settings.json'
|
|
61
|
+
|
|
62
|
+
# The time-boxed witness — the human valve on a blocked verdict. A human types this token so
|
|
63
|
+
# it stands alone on a message's FIRST line, the window holds for ttlMinutes, then blocking
|
|
64
|
+
# resumes on its own. The valve is consulted only AFTER a verdict blocked, so an opened
|
|
65
|
+
# window is always recorded as \`witnessed\` and never silent, and no agent can open one for
|
|
66
|
+
# itself. The token is not a secret: the defence is provenance, not confidentiality.
|
|
67
|
+
#
|
|
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.
|
|
71
|
+
witness:
|
|
72
|
+
token: 'pdks witness'
|
|
73
|
+
ttlMinutes: 10
|
|
74
|
+
`;
|
|
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
|
+
/** The schema's path from a config sitting in `projectRoot`, as the directive spells it. */
|
|
85
|
+
const SCHEMA_REL = 'node_modules/polydeukes/dist/schema/polydeukes.schema.json';
|
|
86
|
+
/**
|
|
87
|
+
* The `yaml-language-server` line for a config written into `projectRoot`, or nothing.
|
|
88
|
+
*
|
|
89
|
+
* An editor resolves a relative `$schema` against the config file's own directory, and this
|
|
90
|
+
* command writes the config where it was invoked — so in a monorepo sub-package, whose install
|
|
91
|
+
* hoisted to the workspace root, {@link SCHEMA_REL} names a path that is not there. The check
|
|
92
|
+
* is one look at that exact path: present means the line an editor would follow leads to the
|
|
93
|
+
* schema, absent means it leads nowhere, and a line leading nowhere is worse than none. An
|
|
94
|
+
* unresolvable `$schema` produces no editor error and no validation, and a line the tool wrote
|
|
95
|
+
* is not one its user thinks to audit.
|
|
96
|
+
*/
|
|
97
|
+
function schemaDirective(projectRoot) {
|
|
98
|
+
return existsSync(join(projectRoot, SCHEMA_REL))
|
|
99
|
+
? `# yaml-language-server: $schema=${SCHEMA_REL}\n`
|
|
100
|
+
: '';
|
|
101
|
+
}
|
|
102
|
+
export function scaffoldProject(projectRoot) {
|
|
103
|
+
const report = { created: [], skipped: [] };
|
|
104
|
+
const found = CONFIG_FILENAMES.filter((name) => existsSync(join(projectRoot, name)));
|
|
105
|
+
if (found.length > 1) {
|
|
106
|
+
throw new Error(`ambiguous Polydeukes config in ${projectRoot} — found ${found.join(' and ')}; ` +
|
|
107
|
+
'keep exactly one and re-run');
|
|
108
|
+
}
|
|
109
|
+
if (found.length === 1) {
|
|
110
|
+
report.skipped.push(found[0]);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
writeFileSync(join(projectRoot, CONFIG_FILENAMES[0]), schemaDirective(projectRoot) + GENERATED_CONFIG);
|
|
114
|
+
report.created.push(CONFIG_FILENAMES[0]);
|
|
115
|
+
}
|
|
116
|
+
// The entry is judged as a whole line. A substring test would treat a commented-out line
|
|
117
|
+
// or a deeper path (`.polydeukes/roi.log`) as coverage and skip the append, leaving the
|
|
118
|
+
// consumer committing their own telemetry — the failure that matters. Whole-line equality
|
|
119
|
+
// errs the other way instead: a project already ignoring the directory under a different
|
|
120
|
+
// spelling (`.polydeukes`, `/.polydeukes/`) gets a second, redundant entry. A duplicate
|
|
121
|
+
// ignore rule costs a line; a missing one costs the consumer's telemetry. Appending is
|
|
122
|
+
// also the only safe write — a `.gitignore` rewritten wholesale takes every entry the
|
|
123
|
+
// consumer already relies on with it.
|
|
124
|
+
const gitignorePath = join(projectRoot, GITIGNORE);
|
|
125
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
126
|
+
if (existing.split('\n').some((line) => line.trim() === TELEMETRY_IGNORE_LINE)) {
|
|
127
|
+
report.skipped.push(GITIGNORE);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// A file that ends without a newline would otherwise absorb the entry into its last
|
|
131
|
+
// line, breaking that entry while never forming ours.
|
|
132
|
+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
133
|
+
appendFileSync(gitignorePath, `${separator}${GITIGNORE_ENTRY}`);
|
|
134
|
+
report.created.push(GITIGNORE);
|
|
135
|
+
}
|
|
136
|
+
return report;
|
|
137
|
+
}
|