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.
@@ -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
+ }