polydeukes 0.4.0 → 0.5.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.
@@ -1,19 +1,20 @@
1
1
  /**
2
- * `initClaudeCode` — the session-surface installer (DIST-02 §3-a/§3-b/§3-g).
2
+ * `initClaudeCode` — the session-surface installer.
3
3
  *
4
4
  * One command wires a project into the session surface: prove the package resolves, run the
5
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).
6
+ * path owns — the delegator hook file, its `.claude/settings.json` registration, the
7
+ * discipline file that tells an agent the docs query exists, and the classification skill
8
+ * that turns a described problem into a config entry.
8
9
  *
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.
10
+ * Preflight comes first and nothing is written before it clears. A generated hook whose
11
+ * import can never resolve blocks every call through its own fail-closed catch, and a tree
12
+ * that also has no config and no valve to open cannot be edited back into shape from inside
13
+ * the session.
13
14
  *
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.
15
+ * Nothing existing is overwritten. The settings file in particular is merged, never
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
18
  */
18
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
20
  import { findPackageJSON } from 'node:module';
@@ -23,24 +24,24 @@ import { isPlainObject } from '@polydeukes/core';
23
24
  import { TOPICS } from './docs-query.js';
24
25
  import { CONFIG_FILENAMES } from './load-config.js';
25
26
  import { scaffoldProject } from './scaffold-project.js';
26
- /** The published entry point the generated hook loads the judge through (§3-c). */
27
+ /** The published entry point the generated hook loads the judge through. */
27
28
  const HOOK_SPECIFIER = 'polydeukes/claude-code';
28
29
  /** The registration artifacts, as `projectRoot`-relative paths (the report vocabulary). */
29
30
  const HOOK_RELATIVE = '.claude/hooks/covenant-pretooluse.mjs';
30
31
  const SETTINGS_RELATIVE = '.claude/settings.json';
31
32
  const DISCOVERY_RELATIVE = '.claude/rules/polydeukes.md';
33
+ const SKILL_RELATIVE = '.claude/skills/discipline-draft/SKILL.md';
32
34
  /**
33
35
  * 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.
36
+ * command already present means already registered. A registration keyed on anything else
37
+ * would be re-added on every run, and the host would then spawn the judge twice per call —
38
+ * every verdict and every telemetry row doubled.
37
39
  */
38
40
  const HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR"/${HOOK_RELATIVE}`;
39
41
  /** Which calls reach the judge — the adapter's own vocabulary, never a copy of it. */
40
42
  const HOOK_MATCHER = [...MUTATING_TOOLS, ...SHELL_TOOLS].join('|');
41
43
  /**
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
+ * The generated hook. It carries no assembly at all, so upgrading the package upgrades the
44
45
  * judge without regenerating this file.
45
46
  */
46
47
  const GENERATED_HOOK = `#!/usr/bin/env node
@@ -71,13 +72,16 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
71
72
  try {
72
73
  const { runClaudeCodeHook } = await import('${HOOK_SPECIFIER}');
73
74
  const { exitCode } = await runClaudeCodeHook({ repoRoot });
74
- process.exit(exitCode);
75
+ // Assign and let the process end naturally instead of process.exit(): an explicit exit
76
+ // can preempt a buffered stderr write on platforms with async pipes, dropping the break
77
+ // reason the agent needs to read.
78
+ process.exitCode = exitCode;
75
79
  } catch (error) {
76
80
  console.error(\`covenant hook failed closed: \${error?.message ?? error}\`);
77
- process.exit(2);
81
+ process.exitCode = 2;
78
82
  }
79
83
  `;
80
- /** What a session is about to do, per topic — the correspondence §3-e asks the file to carry. */
84
+ /** What a session is about to do, per topic — the correspondence the generated file carries. */
81
85
  const DOCS_TOPIC_PURPOSE = {
82
86
  install: 'install Polydeukes, or wire another surface into this project',
83
87
  config: 'edit `polydeukes.config.*` — every key and what reads it',
@@ -86,11 +90,10 @@ const DOCS_TOPIC_PURPOSE = {
86
90
  witness: 'open a blocked call in person',
87
91
  };
88
92
  /**
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.
93
+ * The generated discipline file — the discovery path that gets the query surface called. A
94
+ * query an agent never learns about is a query that does not exist. One scoped file costs
95
+ * nothing while it waits: `paths` frontmatter keeps it out of context until a Polydeukes
96
+ * path is in play.
94
97
  *
95
98
  * Both the command forms and the topic names come from the shipped surface itself — a file
96
99
  * naming a query that exits 2 fails the agent once, and it never calls the command again.
@@ -117,12 +120,176 @@ manager's exec form — from the project root.
117
120
  | --- | --- |
118
121
  ${TOPICS.map((topic) => `| ${DOCS_TOPIC_PURPOSE[topic]} | \`pdks docs ${topic}\` |`).join('\n')}
119
122
  `;
123
+ /**
124
+ * The generated classification skill — the procedure that turns a described problem into a
125
+ * registered entry. A classification procedure an agent never learns about is one that never
126
+ * runs, so it ships as an artifact of the install rather than as prose in a README.
127
+ *
128
+ * Its advise-consumption section is the delivery path for advised rows: the session surface
129
+ * lets an advised call through with exit 0, and the reason never reaches the model at call
130
+ * time — reading the telemetry log at task boundaries is the only way it arrives.
131
+ */
132
+ export const GENERATED_SKILL = `---
133
+ name: discipline-draft
134
+ description: Turn a described discipline problem into a registered entry in polydeukes.config — a judged entry when the current families can express it, a draft entry otherwise. Use when the user describes a recurring problem they want promised away ("I keep...", "stop X from happening", "we should never...", "how do I enforce Y").
135
+ ---
136
+
137
+ # discipline-draft — from a problem description to a registered discipline
138
+
139
+ This project is judged by Polydeukes. A discipline starts as prose and climbs a ladder —
140
+ \`draft\` (registered, read, never judged) → \`advise\` (judged, recorded, never stops a call) →
141
+ \`block\` (stops the call; the user's explicit choice, never the default). This skill walks a
142
+ problem description down to the right first rung and registers it.
143
+
144
+ ## Procedure
145
+
146
+ ### 1. Restate the problem as a promise
147
+
148
+ Rewrite the description as one sentence of the form "X must not happen" or "when A happens,
149
+ B must also happen". If the sentence needs "unless" more than once, split it into two
150
+ promises and classify each separately.
151
+
152
+ ### 2. Classify the shape
153
+
154
+ Ask these questions in order; the first yes decides.
155
+
156
+ | # | Question | Family | Entry key |
157
+ | --- | --- | --- | --- |
158
+ | 1 | Is the promise about content newly ADDED to a file (a pattern that must not appear in new lines)? | delta | \`forbid\` |
159
+ | 2 | Is it about a whole path that must not be modified or deleted (creating it once stays allowed)? | path | \`immutable\` |
160
+ | 3 | Is it about the shell command line itself, regardless of files? | command | \`forbidCommand\` |
161
+ | 4 | Does it require that something else was already done earlier in the session (a tool call that must precede this one)? | context | \`requirePrecedent\` |
162
+ | 5 | None of the above | — | \`draft: true\` (step 4b) |
163
+
164
+ Existing occurrences are forgiven by the delta family — only new additions break the promise.
165
+ That is usually what you want: a discipline adopted today should not indict yesterday's code.
166
+
167
+ Two path-shaped promises take no \`disciplines:\` entry at all. A path nobody may touch
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
+
172
+ ### 3. Check the observation boundary
173
+
174
+ Two kinds of promise cannot be judged here, whatever their shape:
175
+
176
+ - **Destruction outside the repository** — judgment observes the project root only. Register
177
+ nothing; use the agent's own permission deny policy for commands like \`rm -rf ~\`.
178
+ - **Writes by child processes** — a test runner or script writing files is invisible to the
179
+ session surface, which judges declared tool calls only. Say so to the user; the commit
180
+ surface will still see the result as a staged diff.
181
+
182
+ ### 4a. Expressible now — register a judged entry
183
+
184
+ Add the entry to the \`disciplines:\` array in \`polydeukes.config.yaml\`. Advise is the default
185
+ landing — a break is recorded as \`advised\` and the call goes on — and the \`enforce: advise\`
186
+ line below only spells that default out. NEVER write \`enforce: block\` from this skill:
187
+ promotion to block is the user's own choice, made after the advise measurements have been
188
+ read.
189
+
190
+ The examples below are whole documents, so \`languages:\` — the schema's one required block —
191
+ appears alongside the entry; in a config that already has one, copy the entry only.
192
+
193
+ \`\`\`yaml
194
+ languages:
195
+ placeholder:
196
+ productionGlob: 'src/**'
197
+ testCmd: 'echo "set a verification command for {scope}"'
198
+ disciplines:
199
+ - id: 'no-focused-tests'
200
+ why: 'a committed .only silently shrinks the suite to one test'
201
+ forbid: '\\.only\\('
202
+ enforce: advise
203
+ \`\`\`
204
+
205
+ **Write the regex yourself — the user states the promise, you author the pattern.** The
206
+ pattern is the part users find hardest, so never hand the prose back and ask for one. Three
207
+ authoring traps, each measured on a live config:
208
+
209
+ - **A pattern answers a syntactic question only.** "Is this string a forbidden word" is
210
+ 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 \`in:\` to the files where any
212
+ match IS a break (\`in:\`/\`except:\` scope \`forbid\` and \`requirePrecedent\` only), or
213
+ accept "editing this file at all" as the trigger.
214
+ - **\`^\` silently disarms on the delta axis.** \`forbid\` scans whole file content as one
215
+ string, so a line-start anchor matches the first line only — write \`(^|\\n)\` there.
216
+ \`forbidCommand\` judges per line and the whole string, so \`^\` is safe on that axis.
217
+ - **Author both directions.** Before registering, write down one string the pattern must
218
+ match and one nearby string it must not (\`forbid\` vs \`forbidden\`, a flag vs its
219
+ substring). A pattern checked in only the breaking direction over-fires in review-proof
220
+ ways.
221
+
222
+ ### 4b. Not expressible yet — register a draft
223
+
224
+ A draft is prose with a handle: \`id\`, \`why\`, and the literal marker \`draft: true\` — no other
225
+ keys. It produces no judgment and no telemetry; \`pdks explain\` lists it as unpromoted.
226
+ Record the SHAPE of the promise inside \`why\`, so the promotion destination is already
227
+ written down when a later engine can express it. Name the shape in these terms:
228
+
229
+ | Shape | The promise reads like |
230
+ | --- | --- |
231
+ | pairing | every element of set A has a counterpart in set B (translation keys, i18n) |
232
+ | companion | if X appears in a unit, Y must appear with it |
233
+ | ordered | a sequence must keep its order (migration journals, version ladders) |
234
+ | fingerprint | a derived artifact must match the hash/stamp of its source |
235
+ | producer-owned | only a designated generator may write this artifact |
236
+ | self-absolution | the party being judged must not write its own verdict field |
237
+ | actor-scope | the same action is fine for one actor and a break for another |
238
+ | phase-order | several precedents, in a fixed order |
239
+ | turn-locality | the evidence must be in the same turn or time window |
240
+ | stated-ground | the reason must be written down before the action |
241
+ | controlled-vocabulary | only an enumerated set of words/values is allowed |
242
+ | naming-convention | names must match a pattern per kind |
243
+ | irreversible-marker | once present, a marker may never be removed |
244
+ | delegation-scope | a delegated task may touch only its granted scope |
245
+ | scope-valve | a defined exception valve, judged rather than ad hoc |
246
+ | claim-verification | the claim must be re-run/measured, not trusted |
247
+
248
+ \`\`\`yaml
249
+ languages:
250
+ placeholder:
251
+ productionGlob: 'src/**'
252
+ testCmd: 'echo "set a verification command for {scope}"'
253
+ disciplines:
254
+ - id: 'locale-files-move-together'
255
+ why: 'pairing — en.json and ko.json must change in the same commit; one side alone is a break'
256
+ draft: true
257
+ \`\`\`
258
+
259
+ ### 5. Prove it fires, then close
260
+
261
+ Run \`pdks explain\` and confirm the new entry is listed (a judged entry with its family and
262
+ surfaces; a draft as unpromoted).
263
+
264
+ 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 its family can
266
+ actually reach:
267
+
268
+ | Family | Break it once | The entry's id shows up in |
269
+ | --- | --- | --- |
270
+ | \`forbid\` / \`immutable\` | one scratch edit matching the must-match direction | \`pdks covenant check --worktree\` output — the exit stays 0 at advise, the id is the proof |
271
+ | \`forbidCommand\` | run one harmless command matching the pattern | the telemetry log tail — at advise the call proceeds and its row records the id |
272
+ | \`requirePrecedent\` | one in-scope edit made without the required precedent | the telemetry log tail — this family judges on the session surface only (the commit surface records it \`skipped\`) |
273
+
274
+ Then undo the scratch break, repeat the same run, and confirm silence on the
275
+ must-NOT-match direction. Close by telling the user which rung the entry landed on and
276
+ that \`enforce: block\` is theirs to add later if the advise record earns it.
277
+
278
+ ## Reading the advise record
279
+
280
+ An \`advised\` row means a promise was broken and the call went through anyway. Rows land in
281
+ the telemetry log at the path configured by \`telemetry.logPath\` (default
282
+ \`.polydeukes/roi.log\`). The hook's stderr note is not shown to you, so consult the log at
283
+ task boundaries: before committing, or after a batch of edits, read the tail and act on any
284
+ \`advised\` row — fix the break, or tell the user why it should stand. An advisory nobody
285
+ reads measures nothing.
286
+ `;
120
287
  /**
121
288
  * The default preflight: is `polydeukes` installed where `projectRoot` can reach it?
122
289
  *
123
290
  * 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).
291
+ * runs; the CJS alternatives were measured disagreeing with it in both directions.
292
+ * `findPackageJSON` is experimental in Node 24, so its behaviour can still change.
126
293
  */
127
294
  function resolveFromProjectRoot(projectRoot) {
128
295
  // Absence throws here rather than returning undefined (Node 24.18); the branch guards the
@@ -135,7 +302,7 @@ function resolveFromProjectRoot(projectRoot) {
135
302
  // map, so a version predating the session subpath, or one whose dist was never built,
136
303
  // passes a bare-name check while the generated hook fails on every call. That tree cannot
137
304
  // be reopened with the witness token either, because an assembly crash lands before any
138
- // verdict (PR #48 review).
305
+ // verdict.
139
306
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
140
307
  const subpath = isPlainObject(manifest) && isPlainObject(manifest.exports)
141
308
  ? manifest.exports[`./${HOOK_SPECIFIER.split('/')[1]}`]
@@ -209,9 +376,9 @@ function mergeSettings(projectRoot, settings, report) {
209
376
  // Read the registration back rather than assuming the write carried it. The one outcome
210
377
  // this installer must never produce is a successful-looking run whose judge never spawns,
211
378
  // 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.
379
+ // array takes the assignment as a non-index property and `JSON.stringify` discards it.
380
+ // Checking the file instead of the shapes that reach it keeps the question finite: one
381
+ // code path, asked after every write, whatever arrived.
215
382
  if (!carriesRegistration(JSON.parse(readFileSync(settingsPath, 'utf-8')))) {
216
383
  throw new Error(`${SETTINGS_RELATIVE} in ${projectRoot} did not take the PreToolUse registration — ` +
217
384
  'the judge would never be spawned. Fix that file and re-run');
@@ -219,12 +386,12 @@ function mergeSettings(projectRoot, settings, report) {
219
386
  report.created.push(SETTINGS_RELATIVE);
220
387
  }
221
388
  /**
222
- * Install the session surface into `spec.projectRoot` (DIST-02 §3-a), skipping whatever is
223
- * already there and reporting both halves per artifact.
389
+ * Install the session surface into `spec.projectRoot`, skipping whatever is already there
390
+ * and reporting both halves per artifact.
224
391
  *
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.
392
+ * Throws before any write when the package cannot be resolved from that root or when two
393
+ * config spellings already coexist there — both leave zero files. Translating a throw into
394
+ * exit 2 with the install command is the bin's job.
228
395
  */
229
396
  export function initClaudeCode(spec) {
230
397
  const resolvePolydeukes = spec.resolvePolydeukes ?? resolveFromProjectRoot;
@@ -235,21 +402,22 @@ export function initClaudeCode(spec) {
235
402
  // The message names the package because the user's next action is installing it — the
236
403
  // seam's own message cannot be relied on to say so. The original is carried through
237
404
  // 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).
405
+ // an experimental resolver can fail for reasons that are neither.
239
406
  throw new Error(`cannot use 'polydeukes' from ${spec.projectRoot} — install or update it there first ` +
240
407
  "(e.g. 'npm install --save-dev polydeukes'), then run this command again: " +
241
408
  `${error instanceof Error ? error.message : String(error)}`);
242
409
  }
243
- // Every read that can fail is settled before the first write (§5-d invariant 2).
410
+ // Every read that can fail is settled before the first write.
244
411
  const settings = readSettings(spec.projectRoot);
245
412
  const report = scaffoldProject(spec.projectRoot);
246
413
  writeIfAbsent(spec.projectRoot, HOOK_RELATIVE, GENERATED_HOOK, report);
247
414
  mergeSettings(spec.projectRoot, settings, report);
248
415
  // Written last, after the registration the hook needs to ever be spawned. Every write
249
416
  // 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.
417
+ // delegator nothing invokes — a tree that looks installed and is judged by nothing. These
418
+ // two artifacts are the ones whose absence costs only discoverability, so they go where
419
+ // a failure costs least.
253
420
  writeIfAbsent(spec.projectRoot, DISCOVERY_RELATIVE, GENERATED_DISCOVERY, report);
421
+ writeIfAbsent(spec.projectRoot, SKILL_RELATIVE, GENERATED_SKILL, report);
254
422
  return report;
255
423
  }
@@ -1,24 +1,22 @@
1
1
  /**
2
- * Config discovery and loading (CONFIG-03) — the one place allowed to read and parse the
3
- * data config file, so the core stays file-I/O-free.
2
+ * Config discovery and loading — the one place allowed to read and parse the data config
3
+ * file, so the core stays file-I/O-free.
4
4
  *
5
5
  * This lives in its own module rather than in the package barrel because ESM re-exports are
6
- * eager: when `index.ts` re-exports both composition roots, anything importing `loadConfig`
7
- * from the barrel instantiates the session adapter too. That put `@polydeukes/adapter-claude-code`
8
- * on the commit surface's load path, where it is never used a workspace missing only that
9
- * dist would kill `pdks covenant check` before its fail-closed handler could record a row
10
- * (PR #46 review). Both composition roots import this module directly for the same reason.
6
+ * eager: importing `loadConfig` from the barrel would instantiate both composition roots,
7
+ * putting the session adapter on the commit surface's load path where it is never used. A
8
+ * workspace missing only that dist would then kill `pdks covenant check` before its
9
+ * fail-closed handler could record a row. Both composition roots import this module directly
10
+ * for the same reason.
11
11
  */
12
12
  import type { ResolvedConfig } from '@polydeukes/core';
13
13
  /**
14
14
  * The three accepted config filenames, checked directly under the given rootDir. Exported
15
- * for the scaffold (DIST-02 §3-a): its existence check has to see exactly what discovery
16
- * sees, or it would create a second spelling and make every later load ambiguous.
15
+ * for the scaffold: its existence check has to see exactly what discovery sees, or it would
16
+ * create a second spelling and make every later load ambiguous.
17
17
  */
18
18
  export declare const CONFIG_FILENAMES: readonly ['polydeukes.config.yaml', 'polydeukes.config.yml', 'polydeukes.config.json'];
19
- /**
20
- * `LoadedConfig` — the loader's return value (CONFIG-03 §4.1).
21
- */
19
+ /** `LoadedConfig` — the loader's return value. */
22
20
  export type LoadedConfig = {
23
21
  /** defineConfig() resolution — protectedPaths already includes configPath */
24
22
  config: ResolvedConfig;
@@ -26,7 +24,7 @@ export type LoadedConfig = {
26
24
  configPath: string;
27
25
  };
28
26
  /**
29
- * Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
27
+ * Discover, parse, and validate the Polydeukes data config in `rootDir`.
30
28
  *
31
29
  * Discovery looks at exactly the three candidate filenames directly under `rootDir`
32
30
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
@@ -38,6 +36,6 @@ export type LoadedConfig = {
38
36
  *
39
37
  * Before returning, the discovered `configPath` is appended to
40
38
  * `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.
39
+ * protection surface, guaranteed here so no assembler has to remember.
42
40
  */
43
41
  export declare function loadConfig(rootDir: string): LoadedConfig;
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Config discovery and loading (CONFIG-03) — the one place allowed to read and parse the
3
- * data config file, so the core stays file-I/O-free.
2
+ * Config discovery and loading — the one place allowed to read and parse the data config
3
+ * file, so the core stays file-I/O-free.
4
4
  *
5
5
  * This lives in its own module rather than in the package barrel because ESM re-exports are
6
- * eager: when `index.ts` re-exports both composition roots, anything importing `loadConfig`
7
- * from the barrel instantiates the session adapter too. That put `@polydeukes/adapter-claude-code`
8
- * on the commit surface's load path, where it is never used a workspace missing only that
9
- * dist would kill `pdks covenant check` before its fail-closed handler could record a row
10
- * (PR #46 review). Both composition roots import this module directly for the same reason.
6
+ * eager: importing `loadConfig` from the barrel would instantiate both composition roots,
7
+ * putting the session adapter on the commit surface's load path where it is never used. A
8
+ * workspace missing only that dist would then kill `pdks covenant check` before its
9
+ * fail-closed handler could record a row. Both composition roots import this module directly
10
+ * for the same reason.
11
11
  */
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
@@ -15,8 +15,8 @@ import { ConfigValidationError, defineConfig, isPlainObject } from '@polydeukes/
15
15
  import { parseDocument } from 'yaml';
16
16
  /**
17
17
  * The three accepted config filenames, checked directly under the given rootDir. Exported
18
- * for the scaffold (DIST-02 §3-a): its existence check has to see exactly what discovery
19
- * sees, or it would create a second spelling and make every later load ambiguous.
18
+ * for the scaffold: its existence check has to see exactly what discovery sees, or it would
19
+ * create a second spelling and make every later load ambiguous.
20
20
  */
21
21
  export const CONFIG_FILENAMES = [
22
22
  'polydeukes.config.yaml',
@@ -24,7 +24,7 @@ export const CONFIG_FILENAMES = [
24
24
  'polydeukes.config.json',
25
25
  ];
26
26
  /**
27
- * Discover, parse, and validate the Polydeukes data config in `rootDir` (CONFIG-03 §4.1).
27
+ * Discover, parse, and validate the Polydeukes data config in `rootDir`.
28
28
  *
29
29
  * Discovery looks at exactly the three candidate filenames directly under `rootDir`
30
30
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
@@ -36,7 +36,7 @@ export const CONFIG_FILENAMES = [
36
36
  *
37
37
  * Before returning, the discovered `configPath` is appended to
38
38
  * `config.protectedPaths` unless already present — the config file itself joins the
39
- * protection surface (schema rule 6), guaranteed here so no assembler has to remember.
39
+ * protection surface, guaranteed here so no assembler has to remember.
40
40
  */
41
41
  export function loadConfig(rootDir) {
42
42
  const found = CONFIG_FILENAMES.filter((name) => existsSync(join(rootDir, name)));
@@ -1,28 +1,37 @@
1
1
  /**
2
- * `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
2
+ * `scaffoldProject` — the project-side scaffold layer.
3
3
  *
4
4
  * The half of an installation every distribution path shares: the data config the judges
5
5
  * read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
6
6
  * agent is told to spawn a judge at all — and that lives one layer up (`initClaudeCode` for
7
- * the `init` path, a manifest for the plugin one). The split is what lets a second path
8
- * reuse this function unchanged instead of scaffolding a config a second time, so nothing
9
- * that registers anything belongs here.
7
+ * the `init` path). The split is what lets a second path reuse this function unchanged
8
+ * instead of scaffolding a config a second time, so nothing that registers anything belongs
9
+ * here.
10
10
  *
11
- * Nothing existing is ever overwritten (§5-d invariant 1): an artifact that is already there
12
- * is reported and left alone. The config existence check reads all three discovery
13
- * candidates rather than the canonical name alone — writing `polydeukes.config.yaml` next to
14
- * a project's `.yml` makes {@link loadConfig} throw on ambiguity, and the fail-closed session
15
- * surface then blocks every call, so the installer itself would be what stopped the project.
16
- * Existence is FILE PRESENCE, never parse success: reading a broken config as "absent" would
17
- * destroy the very file the consumer was midway through fixing, and fixing it is their job.
11
+ * Nothing existing is ever overwritten: an artifact that is already there is reported and
12
+ * left alone. The config existence check reads all three discovery candidates rather than
13
+ * the canonical name alone — writing `polydeukes.config.yaml` next to a project's `.yml`
14
+ * makes {@link loadConfig} throw on ambiguity, and the fail-closed session surface then
15
+ * blocks every call, so the installer itself would be what stopped the project. Existence is
16
+ * FILE PRESENCE, never parse success: reading a broken config as "absent" would destroy the
17
+ * very file the consumer was midway through fixing, and fixing it is their job.
18
18
  */
19
19
  /**
20
- * Per-artifact outcome, as `projectRoot`-relative paths — the bin prints it (§3-a stdout
21
- * contract). `created` names what this run wrote, `skipped` what it found and left alone; a
22
- * silent skip would leave the user unable to tell an idempotent no-op from a failed run.
20
+ * Per-artifact outcome, as `projectRoot`-relative paths — the bin prints it. `created` names
21
+ * what this run wrote, `skipped` what it found and left alone; a silent skip would leave the
22
+ * user unable to tell an idempotent no-op from a failed run.
23
23
  */
24
24
  export type ScaffoldReport = {
25
25
  created: string[];
26
26
  skipped: string[];
27
27
  };
28
+ /**
29
+ * Create the project-side artifacts of a Polydeukes installation in `projectRoot`, skipping
30
+ * whatever is already there.
31
+ *
32
+ * Throws when two or more config spellings coexist — that tree is already stopped, since
33
+ * {@link loadConfig} refuses an ambiguous discovery, and adding artifacts to it would wire a
34
+ * judge whose every call fails closed. The throw lands before any write, so a human deleting
35
+ * one config is all it takes to reopen the path.
36
+ */
28
37
  export declare function scaffoldProject(projectRoot: string): ScaffoldReport;
@@ -1,36 +1,36 @@
1
1
  /**
2
- * `scaffoldProject` — the project-side scaffold layer (DIST-02 §3-i).
2
+ * `scaffoldProject` — the project-side scaffold layer.
3
3
  *
4
4
  * The half of an installation every distribution path shares: the data config the judges
5
5
  * read, and the telemetry ignore line. What differs between paths is REGISTRATION — how the
6
6
  * agent is told to spawn a judge at all — and that lives one layer up (`initClaudeCode` for
7
- * the `init` path, a manifest for the plugin one). The split is what lets a second path
8
- * reuse this function unchanged instead of scaffolding a config a second time, so nothing
9
- * that registers anything belongs here.
7
+ * the `init` path). The split is what lets a second path reuse this function unchanged
8
+ * instead of scaffolding a config a second time, so nothing that registers anything belongs
9
+ * here.
10
10
  *
11
- * Nothing existing is ever overwritten (§5-d invariant 1): an artifact that is already there
12
- * is reported and left alone. The config existence check reads all three discovery
13
- * candidates rather than the canonical name alone — writing `polydeukes.config.yaml` next to
14
- * a project's `.yml` makes {@link loadConfig} throw on ambiguity, and the fail-closed session
15
- * surface then blocks every call, so the installer itself would be what stopped the project.
16
- * Existence is FILE PRESENCE, never parse success: reading a broken config as "absent" would
17
- * destroy the very file the consumer was midway through fixing, and fixing it is their job.
11
+ * Nothing existing is ever overwritten: an artifact that is already there is reported and
12
+ * left alone. The config existence check reads all three discovery candidates rather than
13
+ * the canonical name alone — writing `polydeukes.config.yaml` next to a project's `.yml`
14
+ * makes {@link loadConfig} throw on ambiguity, and the fail-closed session surface then
15
+ * blocks every call, so the installer itself would be what stopped the project. Existence is
16
+ * FILE PRESENCE, never parse success: reading a broken config as "absent" would destroy the
17
+ * very file the consumer was midway through fixing, and fixing it is their job.
18
18
  */
19
19
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { CONFIG_FILENAMES } from './load-config.js';
22
- /** `.gitignore` name and the telemetry directory entry it must carry (§3-a). */
22
+ /** `.gitignore` name and the telemetry directory entry it must carry. */
23
23
  const GITIGNORE = '.gitignore';
24
24
  const TELEMETRY_IGNORE_LINE = '.polydeukes/';
25
25
  const GITIGNORE_ENTRY = `# Polydeukes telemetry — local observation data, never committed.\n${TELEMETRY_IGNORE_LINE}\n`;
26
26
  /**
27
- * The generated config: the §3-d minimum protection set and the §3-e witness block, both
28
- * mandatory. Emitted as a literal template rather than serialized from an object because
29
- * the comments ARE the artifact — a consumer's first contact with the protection surface is
30
- * reading why each entry is on it.
27
+ * The generated config: the minimum protection set and the witness block, both mandatory.
28
+ * Emitted as a literal template rather than serialized from an object because the comments
29
+ * ARE the artifact — a consumer's first contact with the protection surface is reading why
30
+ * each entry is on it.
31
31
  *
32
32
  * {@link schemaDirective} prepends the `yaml-language-server` line when the schema is where
33
- * that line would name it (DIST-05 §3-b).
33
+ * that line would name it.
34
34
  */
35
35
  const GENERATED_CONFIG = `# Polydeukes protection policy — generated by \`pdks init claude-code\`.
36
36
  #
@@ -55,6 +55,9 @@ languages:
55
55
  # layer that can watch it happen.
56
56
  #
57
57
  # A minimum. Add entries as you find you want them.
58
+ #
59
+ # This list is what blocks. Every \`disciplines:\` entry below lands at advise — a break is
60
+ # recorded and the call goes on — unless the entry itself says \`enforce: block\`.
58
61
  protectedPaths:
59
62
  - '.claude/hooks'
60
63
  - '.claude/settings.json'
@@ -71,16 +74,29 @@ protectedPaths:
71
74
  witness:
72
75
  token: 'pdks witness'
73
76
  ttlMinutes: 10
77
+
78
+ # The disciplines you judge by, and the three rungs one climbs — shown as three entries so
79
+ # each rung is a line you can copy. Uncomment to start; ids must stay distinct.
80
+ #
81
+ # disciplines:
82
+ # # A draft: prose only, no predicate. Registered and read, never judged.
83
+ # - id: 'no-todo-in-shipped-code-draft'
84
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
85
+ # draft: true
86
+ #
87
+ # # Promoted to a judgment. Advise is the default — recorded as \`advised\`, never stops
88
+ # # the call — so this line is optional; it is written here to show the rung.
89
+ # - id: 'no-todo-in-shipped-code'
90
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
91
+ # forbid: 'TODO'
92
+ # enforce: advise
93
+ #
94
+ # # The promotion — block is your choice, never the default.
95
+ # - id: 'no-todo-in-shipped-code-blocking'
96
+ # why: 'a TODO nobody owns is a decision deferred out of sight'
97
+ # forbid: 'TODO'
98
+ # enforce: block
74
99
  `;
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
100
  /** The schema's path from a config sitting in `projectRoot`, as the directive spells it. */
85
101
  const SCHEMA_REL = 'node_modules/polydeukes/dist/schema/polydeukes.schema.json';
86
102
  /**
@@ -99,6 +115,15 @@ function schemaDirective(projectRoot) {
99
115
  ? `# yaml-language-server: $schema=${SCHEMA_REL}\n`
100
116
  : '';
101
117
  }
118
+ /**
119
+ * Create the project-side artifacts of a Polydeukes installation in `projectRoot`, skipping
120
+ * whatever is already there.
121
+ *
122
+ * Throws when two or more config spellings coexist — that tree is already stopped, since
123
+ * {@link loadConfig} refuses an ambiguous discovery, and adding artifacts to it would wire a
124
+ * judge whose every call fails closed. The throw lands before any write, so a human deleting
125
+ * one config is all it takes to reopen the path.
126
+ */
102
127
  export function scaffoldProject(projectRoot) {
103
128
  const report = { created: [], skipped: [] };
104
129
  const found = CONFIG_FILENAMES.filter((name) => existsSync(join(projectRoot, name)));