polydeukes 0.3.0 → 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,294 @@
1
+ # Configuration reference
2
+
3
+ **English** · [한국어](./configuration.ko.md)
4
+
5
+ Every key of `polydeukes.config.yaml`, one section per key. The guide — what the file is,
6
+ how discovery fails, and the IDE wiring — is
7
+ [Configuring Polydeukes](../configuration.md), and what a verdict looks like when a
8
+ discipline fires is its
9
+ [What enforcement looks like](../configuration.md#what-enforcement-looks-like) section.
10
+
11
+ ## `languages`
12
+
13
+ Required. The language axis, first-class. Keys are your values (`typescript`, `python`, …) —
14
+ the core ships no language names and never interprets the command string.
15
+
16
+ ```yaml
17
+ languages:
18
+ typescript:
19
+ productionGlob: 'packages/*/src/**/*.ts' # what counts as production source
20
+ testCmd: 'pnpm --filter {scope} test' # {scope} is substituted at resolve time
21
+ ```
22
+
23
+ `testCmd` is a template string, not a function. Every `{scope}` occurrence is replaced;
24
+ all other braces (`${VAR}`, `{a,b}`, `awk '{print}'`) pass through untouched. A command
25
+ that ignores scope (`pnpm test`) is equally valid.
26
+
27
+ ## `protectedPaths`
28
+
29
+ Optional. Raw path patterns whose files the covenants protect from modification — by
30
+ editor tools and by shell commands alike (`sed -i`, `tee`, redirects, heredocs,
31
+ parent-directory moves). Entries are normalized (trimmed, deduplicated) at resolve time.
32
+ An empty-string entry is rejected at load time — it carries no path meaning.
33
+
34
+ ```yaml
35
+ protectedPaths:
36
+ - 'packages/core/src'
37
+ - '.claude/hooks'
38
+ ```
39
+
40
+ **The config file protects itself.** The discovered config file is automatically appended
41
+ to `protectedPaths` — an edit that would lower your own gates goes through the same judge
42
+ as everything else. If the file that declares the disciplines were not itself under the
43
+ disciplines, the whole chain would be decoration.
44
+
45
+ ## `adapters`
46
+
47
+ Optional. Adapter namespaces. One config file, one namespace per adapter: each key names an
48
+ adapter, and its value is that adapter's own settings object. The core validates the
49
+ container shape only — the keys and the contents belong to each adapter, which ships
50
+ its own validator for its own vocabulary. An unknown key *inside* a namespace is
51
+ rejected by that adapter's validator, with the full field path in the error.
52
+
53
+ ```yaml
54
+ adapters:
55
+ git:
56
+ enforce: advise
57
+ protectedPaths:
58
+ - 'packages/core/src'
59
+ ```
60
+
61
+ ### `adapters.git` — the git commit adapter
62
+
63
+ | Key | Values | Default | Meaning |
64
+ |---|---|---|---|
65
+ | `enforce` | `block` \| `advise` | `block` | Enforcement level of the commit surface |
66
+ | `protectedPaths` | string array | `[]` | Additive protection scope judged by the commit surface only |
67
+
68
+ - **`block`** — a staged change that breaks a covenant blocks the commit (exit 2). The
69
+ only way through is the witness valve: a human answering the TTY prompt with the full
70
+ token. The prompt names what it asks the human to witness — the broken registration,
71
+ the matched entry, and the fact that the one answer covers the whole commit. An absent
72
+ namespace, an absent `adapters` map, or an absent `enforce` key all mean `block` — not
73
+ writing the key selects the strictest level.
74
+ - **`advise`** — the commit surface becomes a backstop without a block: a verdict on a
75
+ staged change is recorded as an `advised` telemetry event and the commit proceeds
76
+ (exit 0) with one advisory line on stderr. No TTY prompt fires. Only the verdict is
77
+ relaxed — a run that cannot judge (missing or invalid config, an unresolvable judge
78
+ body) still fails closed at exit 2, at either level.
79
+
80
+ **`protectedPaths` here is an additive scope.** The commit surface judges the union of the
81
+ top-level `protectedPaths` and this list — concatenated (common first) and normalized as one,
82
+ so spelling and dedupe rules are identical for both. The session surface never reads it: the
83
+ list exists for paths whose edit is legitimate work during a session but must pass a judged
84
+ checkpoint when it is promoted into repository history — a judgment chain's own sources are
85
+ the canonical tenant. As the enforcement level is the observer's setting, so is the
86
+ additional scope. There is no subtractive vocabulary: a config line can widen a surface's
87
+ scope, never quietly strip one.
88
+
89
+ The session surface (the editor-time hook) has no level setting here; it always blocks.
90
+
91
+ **Context-family disciplines skip on the commit surface.** A commit has no session to look
92
+ at, so a `requirePrecedent` entry cannot be judged there — demanding evidence a commit
93
+ cannot carry would block every matching commit with no legitimate way through.
94
+
95
+ They are not filtered out, though. They assemble like any other discipline and become
96
+ *skip registrations*: routing intact, no judge body. When one matches a staged change it
97
+ records a `skipped` telemetry event and lets the commit proceed. The record carries the
98
+ entry's `id` and the change it would have judged, so a gate that did nothing says so in
99
+ the data — and it appears **only when the entry's scope actually matched**, so a commit
100
+ touching nothing the entry cares about records nothing at all.
101
+
102
+ This is the same disposition the session surface uses whenever it has no transcript to
103
+ read. One rule, both surfaces: evidence that cannot be evaluated is skipped and measured,
104
+ never blocked and never silent.
105
+
106
+ ## `telemetry`
107
+
108
+ Optional.
109
+
110
+ ```yaml
111
+ telemetry:
112
+ logPath: '.polydeukes/roi.log' # default when omitted; keep it gitignored
113
+ ```
114
+
115
+ Every judgment — passed, blocked, witnessed, advised, or skipped — appends one record.
116
+ Telemetry is fail-open by design: a logging failure never changes a verdict. The path
117
+ itself is still validated at load time — an empty or whitespace-only `logPath` is
118
+ rejected.
119
+
120
+ ## `witness`
121
+
122
+ Optional.
123
+
124
+ ```yaml
125
+ witness:
126
+ token: 'covenant witness' # the phrase a human types in the conversation
127
+ ttlMinutes: 10 # validity window, in minutes, from that message
128
+ ```
129
+
130
+ The values of the time-boxed human valve, consumed where the covenants are assembled.
131
+ The valve is sudo, not an exemption: the one property a deterministic gate can compute
132
+ about a judgment chain is "is an accountable human present, right now", and the witness
133
+ is that human supplying the pass condition in person. When a covenant blocks a
134
+ legitimate edit, a human types the agreed token into the conversation; blocked judgments
135
+ can be witnessed open for `ttlMinutes` from that message's timestamp, then blocking
136
+ resumes automatically. Both keys are required when the section is present: the token
137
+ must be non-empty after trimming, the window a finite number greater than zero.
138
+
139
+ **The valve stands after the verdict, never instead of it.** The judge body always runs.
140
+ A call that would have passed anyway never consults the valve, so an open window changes
141
+ nothing about clean work — and a `witnessed` telemetry row therefore always names a real
142
+ block a human answered for, never a ritual. Only a judgment that actually blocked can be
143
+ witnessed open.
144
+
145
+ **The token must stand alone on the message's first line.** Invoking the witness is
146
+ distinct from talking about it: a message that quotes, questions, or explains the token
147
+ mid-sentence — or wraps it in backticks — does not open the valve, while a first line
148
+ carrying the token alone does, with any following lines free for the work itself.
149
+
150
+ A message that invokes — the token alone on the first line, the rest free:
151
+
152
+ ```text
153
+ covenant witness
154
+
155
+ now fix the hook file
156
+ ```
157
+
158
+ A message that merely mentions — the valve stays shut:
159
+
160
+ ```text
161
+ so when does `covenant witness` expire?
162
+ ```
163
+
164
+ The token's value is free — any phrase works, and it is never checked for a prefix or a
165
+ command shape. Only its placement is constrained.
166
+
167
+ The token is not a secret — the defense is provenance, not secrecy. A witness counts only
168
+ when the token arrives in a message positively identified as human-typed in the session
169
+ transcript, so an AI that knows the token still cannot forge one. Witnessed judgments are
170
+ recorded as `witnessed`, never silent.
171
+
172
+ ## `disciplines`
173
+
174
+ Optional. Each entry is one discipline: a practice the team imposes on itself, declared as
175
+ data. An entry carries exactly **one** predicate (zero or two is rejected), an `id` (the
176
+ telemetry label), and optionally a `why` (the reason, kept next to the rule) plus, on a
177
+ `forbid` or `requirePrecedent` entry, `in` (the file globs it judges) and `except` (globs
178
+ carved out of that scope).
179
+
180
+ **`forbid` — content delta.** Blocks an edit that *adds* a new match of the pattern.
181
+ Existing occurrences are forgiven: adopting a discipline never blocks a legacy codebase,
182
+ because the judgment direction is "what did this edit add", not "what does the file
183
+ contain".
184
+
185
+ ```yaml
186
+ disciplines:
187
+ - id: 'covenant-vocabulary'
188
+ why: 'control-framing vocabulary is banned in package sources.'
189
+ in:
190
+ - 'packages/*/src/**'
191
+ forbid: '\b(guard|harness|kb)\b'
192
+ ```
193
+
194
+ **`immutable` — path family.** Blocks modification of existing files that match; creating
195
+ new files is allowed.
196
+
197
+ ```yaml
198
+ - id: 'archived-records-stay-frozen'
199
+ why: 'an archive that can be edited is not an archive.'
200
+ immutable: 'records/archive/**'
201
+ ```
202
+
203
+ **`forbidCommand` — command family.** Blocks shell commands matching the pattern, even
204
+ when the command mentions no protected path. This is how gate-disarming commands are
205
+ caught. A multi-line command is judged twice over — the pattern is tested against each
206
+ line and against the whole string, so `^` means the start of a line while a pattern
207
+ spanning a line boundary still matches (the whole-content caution further down applies
208
+ to the delta and context families). An empty pattern is rejected at load time, here and
209
+ on `forbid` alike — it would match every command.
210
+
211
+ ```yaml
212
+ - id: 'hooks-stay-armed'
213
+ why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.'
214
+ forbidCommand: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath'
215
+ ```
216
+
217
+ **`requirePrecedent` — context family.** Blocks a change that arrives without a required
218
+ step having happened earlier in the session. The other three families all ask "is this
219
+ change itself bad"; this one asks something else. The change is legitimate — what is
220
+ missing is the procedure in front of it, so what gets judged is not the mutation but the
221
+ session history.
222
+
223
+ Evidence means an **execution**, not a request. A call the covenant blocked, one a human
224
+ refused, and one that simply failed all leave the same trace in a session, and none of
225
+ them is precedent — the transcript is read for what actually ran and reported success.
226
+ That is what keeps the cheapest way through the gate being the thing the discipline
227
+ asks for.
228
+
229
+ Two consequences are worth knowing before you write one. The outcome is read per command
230
+ LINE, so a chain where the required command ran but a later step failed does not count.
231
+ And the pattern is matched at the start of a simple command, so the same words in an
232
+ argument or a comment do not count either. **In both cases running the command on its own
233
+ opens the gate** — the block message says so.
234
+
235
+ ```yaml
236
+ - id: 'dependency-needs-npm-view'
237
+ why: 'a dependency version must be measured before it is written.'
238
+ in:
239
+ - 'package.json'
240
+ - 'packages/*/package.json'
241
+ when: '(^|\n)\s*"[^"]+"\s*:\s*"[~^]?\d[^"]*"'
242
+ requirePrecedent:
243
+ command: 'npm view '
244
+ ```
245
+
246
+ The evidence vocabulary is layered. `command` is the core's own key — a shell call is a surface
247
+ every agent shares — and the core validates it fully, rejecting an empty string or a pattern that
248
+ does not compile. It is matched **at the start of a simple command**, not anywhere in the command
249
+ line, so `echo "npm view yaml"` and a mention parked behind a `#` are not evidence while `cd pkg &&
250
+ npm view yaml` is. Every other key belongs to an adapter: the core checks the container only (a flat
251
+ object carrying exactly one evidence key) and passes the value through verbatim, and the adapter
252
+ that owns the word validates and judges it. The Claude Code adapter brings two: `subagent` (exact
253
+ match on a spawn kind) and `tool` (a regex over tool names) — so "query the docs tool before
254
+ touching this" is expressible today. Both follow the same execution rule as `command`. An evidence
255
+ key no assembled adapter recognizes cannot be judged, so the entry compiles to a skip registration:
256
+ routing stays, the body is dropped, assembly names the fault once on stderr, and every matching
257
+ change afterwards records `skipped` rather than a verdict. A typo therefore never passes itself off
258
+ as adapter vocabulary — but it does leave the discipline inert, and the `skipped` rows are where
259
+ that shows.
260
+
261
+ `when` (optional) is the trigger: an added-direction delta regex, combinable with
262
+ `requirePrecedent` and with nothing else. When it is absent, every change inside `in`
263
+ scope triggers the discipline. The two keys divide the work — `in` says which files are
264
+ watched, `when` says which change in them demands the precedent.
265
+
266
+ **A caution on line anchors.** These patterns are matched against the file's whole content
267
+ as a single string, and the config schema takes a regex string with no flags. `^` therefore
268
+ anchors to the start of the *file*, not the start of a line, so a line-shaped pattern
269
+ written with `^` matches only the first line and the discipline silently stops firing —
270
+ the regex still compiles, the judgment still runs, and the verdict is `passed`. Write
271
+ `(^|\n)` when you mean the start of a line. This is why the example above carries
272
+ `(^|\n)\s*"[^"]+"…` rather than `^\s*"[^"]+"…`.
273
+
274
+ **And a caution on match length.** The delta keys on the matched *text*: a change is only
275
+ seen as added when the matched string itself differs between the file's before and after.
276
+ A pattern that stops mid-value — say at the first digit of a version — produces the same
277
+ match text for `4.0.5` and `4.0.6`, so a version bump adds nothing to the delta and the
278
+ discipline silently passes. Make the pattern span the whole value that can change; the
279
+ example above runs through the closing quote (`\d[^"]*"`) for exactly this reason. Both
280
+ failure shapes are the same class: the regex compiles, the verdict says `passed`, and
281
+ nothing tells you the discipline is inert — so when you add an entry, measure it against
282
+ a real file and a realistic edit, not a one-line snippet.
283
+
284
+ The kind of change matters at the trigger. With `when` present, a deletion never triggers
285
+ — deleting adds no content. With `when` absent, deletion triggers like any other change in
286
+ scope, since the declared scope is the whole mutation.
287
+
288
+ **The cheap way through is the honest one.** Unlike the witness, this evidence lives on the
289
+ AI's own surface, so it is not forgery-proof. It does not need to be: the least effortful
290
+ way to open this gate is to actually call the tool, and that is exactly the behaviour the
291
+ discipline exists to induce.
292
+
293
+ Adding a discipline is a data edit — no code, no plumbing. Custom judge bodies remain the
294
+ escape layer for the few rules data cannot express.
@@ -83,10 +83,10 @@ attribution. Calls without evidence are skipped, never substituted for.
83
83
  Three places, all of them indirect.
84
84
 
85
85
  - **The config file.** Its schema is defined here. The vocabulary reference is
86
- [configuration.md](../configuration.md).
87
- - **The JSON Schema artifact** — `@polydeukes/core/schema.json`, an exports subpath. Under
88
- a default pnpm layout it does not resolve from a consumer's top-level `node_modules`,
89
- because the core arrives as a transitive dependency; the working spellings are in
86
+ [the configuration reference](./configuration.md).
87
+ - **The JSON Schema artifact** — `@polydeukes/core/schema.json`, an exports subpath, for a
88
+ project that installs this package directly. A consumer of the umbrella names the copy
89
+ bundled there instead; both spellings are in
90
90
  [configuration.md's IDE section](../configuration.md#ide-support).
91
91
  - **The protocol above** — reading a `blocked` row means reading the vocabulary a body
92
92
  answered in.
@@ -37,7 +37,7 @@ determines whether it can be judged on a given surface.
37
37
 
38
38
  `when` is a trigger, not a family: it narrows a `requirePrecedent` entry and combines with
39
39
  nothing else. The writing guide for these entries — the four predicate forms, the two
40
- pitfalls — is [configuration.md's `disciplines` section](../configuration.md#disciplines-optional).
40
+ pitfalls — is [the configuration reference's `disciplines` section](./configuration.md#disciplines).
41
41
 
42
42
  **Three meta-covenants** protect the judging chain. They are covenants like any other; the
43
43
  vocabulary below applies to them unchanged.
@@ -48,9 +48,9 @@ vocabulary below applies to them unchanged.
48
48
  | shell-mod | Shell | The same, through a command line. A command mentioning a protected path passes only if its leading word proves it read-only |
49
49
  | transcript-mod | Transcript | Writes to the live session transcript, judged by whole-path **equality** — never as a protected ancestor |
50
50
 
51
- **Five verdict words** are the telemetry contract. A row in `.polydeukes/roi.log` carries
52
- exactly one of them, and the CLI, the docs, and the tests use the same word for the same
53
- event. How to read a row is in
51
+ **Six words** are the telemetry contract — five verdicts and one observation. A row in
52
+ `.polydeukes/roi.log` carries exactly one of them, and the CLI, the docs, and the tests use
53
+ the same word for the same event. How to read a row is in
54
54
  [troubleshooting](../troubleshooting.md#reading-a-verdict).
55
55
 
56
56
  | Verdict | Means |
@@ -60,6 +60,14 @@ event. How to read a row is in
60
60
  | `witnessed` | A **blocked** verdict a human opened in person. Never silent, never a clean call |
61
61
  | `advised` | The commit surface at `enforce: advise` recorded a break without stopping it |
62
62
  | `skipped` | The call reached a registration that could not judge it. **Not a pass** — the recorded absence of a judgment |
63
+ | `unattributed` | A protected entry's on-disk state moved and no judgment row explains it. **Not a verdict** — no call is blocked or passed by it; the session surface writes it after comparing state against a stored baseline |
64
+
65
+ `unattributed` answers a question the other five cannot. They are all written by a judge
66
+ about a call it was handed, so a write that arrives without a declared call — through an
67
+ interpreter, a test runner's child process, a script that assembles the path from its own
68
+ arguments — leaves no row at all. The comparison observes the result rather than the
69
+ spelling, so it records that write after the fact. It never blocks: the write already
70
+ happened, and the comparison fails open on both sides of the verdict.
63
71
 
64
72
  ## Where the consumer touches it
65
73
 
@@ -78,10 +78,10 @@ package, so the answer comes from the installed version rather than from the net
78
78
  | Topic | Answers from |
79
79
  |---|---|
80
80
  | `install` | [installation](../installation.md), in full |
81
- | `config` | [configuration](../configuration.md) the Reference section |
82
- | `discipline` | [configuration](../configuration.md) — the `disciplines` section |
81
+ | `config` | [the configuration reference](./configuration.md), in full |
82
+ | `discipline` | [the configuration reference](./configuration.md) — the `disciplines` section |
83
83
  | `covenant` | [configuration](../configuration.md) — What enforcement looks like |
84
- | `witness` | [configuration](../configuration.md)'s `witness` section, then [troubleshooting](../troubleshooting.md)'s valve section |
84
+ | `witness` | [the configuration reference](./configuration.md)'s `witness` section, then [troubleshooting](../troubleshooting.md)'s valve section |
85
85
 
86
86
  **Every failure leaves stdout at zero bytes.** A missing bundled document, a heading the
87
87
  document no longer carries, an unknown topic — each names what was missing on stderr and
@@ -203,8 +203,13 @@ needs no second dependency.
203
203
  |---|---|
204
204
  | `polydeukes` | The barrel — `loadConfig`, `runCovenantCheck`, `runClaudeCodeHook`, their spec types, `ResolvedConfig` |
205
205
  | `polydeukes/claude-code` | `runClaudeCodeHook` and `ClaudeCodeHookSpec` alone |
206
+ | `polydeukes/schema.json` | The config JSON Schema, copied from the core at build time |
206
207
 
207
208
  The generated hook delegator imports the subpath, not the barrel. ESM imports are eager, so
208
209
  importing the barrel would load the commit-surface runner — and the git adapter behind it —
209
210
  on every session tool call that will never use them. The subpath is the session surface's
210
211
  own entry point, and the barrel is for programmatic consumers.
212
+
213
+ `polydeukes/schema.json` is for code that reads the schema. A `$schema` line names the file
214
+ path instead — an editor reads that string statically, so no module resolver runs on it. Both
215
+ spellings are in [configuration.md's IDE section](../configuration.md#ide-support).
@@ -6,6 +6,9 @@
6
6
  > proceed, and four things worth knowing when a judgment surprises you. Each entry is
7
7
  > symptom → cause → recovery.
8
8
 
9
+ This is the guide layer for recovery: the fail-closed states, reading verdicts, and the
10
+ witness valve.
11
+
9
12
  The one principle behind half of this page: **a gate that cannot judge blocks rather than
10
13
  guesses.** A missing config, an ambiguous config, an invalid config, an installer that
11
14
  cannot prove resolution, and a judge that cannot be loaded all fail closed, because a dead
@@ -92,7 +95,7 @@ resolves again.
92
95
  one record to the telemetry log (`.polydeukes/roi.log` by default, `telemetry.logPath` to
93
96
  move it).
94
97
 
95
- **Recovery.** Read the last lines and the five-word vocabulary:
98
+ **Recovery.** Read the last lines and the six-word vocabulary:
96
99
 
97
100
  | Word | Means |
98
101
  |---|---|
@@ -101,6 +104,11 @@ move it).
101
104
  | `witnessed` | A blocked verdict a human opened in person. Never silent. |
102
105
  | `advised` | Commit surface at `advise` level: a break recorded without stopping the commit. |
103
106
  | `skipped` | A registration matched but could not judge — **the recorded absence of a judgment, not a pass.** |
107
+ | `unattributed` | A protected entry changed on disk and no judgment explains it — **an observation, not a verdict.** Nothing was blocked; the write already happened. |
108
+
109
+ An `unattributed` row names the entry, not the file inside it. Rebuilding a protected `dist`
110
+ without a judged call producing one is expected — it says a write reached that entry outside
111
+ the session's view, which is exactly what the row is for.
104
112
 
105
113
  ## Opening a blocked call — the witness
106
114
 
@@ -24,11 +24,11 @@ const TOPIC_MAP = {
24
24
  seeAlso: 'reference/polydeukes.md',
25
25
  },
26
26
  config: {
27
- sections: [{ file: 'configuration.md', heading: '## Reference' }],
27
+ sections: [{ file: 'reference/configuration.md' }],
28
28
  seeAlso: 'reference/core.md',
29
29
  },
30
30
  discipline: {
31
- sections: [{ file: 'configuration.md', heading: '### `disciplines` (optional)' }],
31
+ sections: [{ file: 'reference/configuration.md', heading: '## `disciplines`' }],
32
32
  seeAlso: 'reference/covenant.md',
33
33
  },
34
34
  covenant: {
@@ -37,7 +37,7 @@ const TOPIC_MAP = {
37
37
  },
38
38
  witness: {
39
39
  sections: [
40
- { file: 'configuration.md', heading: '### `witness` (optional)' },
40
+ { file: 'reference/configuration.md', heading: '## `witness`' },
41
41
  { file: 'troubleshooting.md', heading: '## Opening a blocked call — the witness' },
42
42
  ],
43
43
  seeAlso: 'reference/covenant.md',
@@ -129,10 +129,10 @@ export function queryDocs(spec) {
129
129
  }
130
130
  const entry = TOPIC_MAP[spec.topic];
131
131
  const body = entry.sections.map((section) => readSection(spec.docsRoot, section)).join('\n');
132
- // Resolved against the bundle, not printed as the bare relative name. The reference layer
133
- // ships but no topic reads it, so this line is the only way to reach those five documents
134
- // and a reader given `reference/core.md` has to guess where the bundle lives before it can
135
- // open anything. A path a file-read tool can take is the difference between a pointer and
136
- // a dead end, and a dead end sends the reader back to the web search this command replaces.
132
+ // Resolved against the bundle, not printed as the bare relative name. A reader given
133
+ // `reference/core.md` has to guess where the bundle lives before it can open anything, and
134
+ // this line is the only way most of the reference layer is reached at all. A path a file-read
135
+ // tool can take is the difference between a pointer and a dead end, and a dead end sends the
136
+ // reader back to the web search this command replaces.
137
137
  return { text: `${body}\nSee also: ${join(spec.docsRoot, entry.seeAlso)}\n` };
138
138
  }
@@ -32,8 +32,9 @@ export type LoadedConfig = {
32
32
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
33
33
  * zero files found (message names all three candidates), two or more found (message
34
34
  * names the collisions), a parse error or unresolved custom tag (safe core schema —
35
- * config data is never executable), or a `ConfigValidationError` from core
36
- * `defineConfig()` (re-thrown with file-path context, keeping the error type).
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).
37
38
  *
38
39
  * Before returning, the discovered `configPath` is appended to
39
40
  * `config.protectedPaths` unless already present — the config file itself joins the
@@ -30,8 +30,9 @@ export const CONFIG_FILENAMES = [
30
30
  * (no upward walk). Every failure branch throws — silent defaults are forbidden:
31
31
  * zero files found (message names all three candidates), two or more found (message
32
32
  * names the collisions), a parse error or unresolved custom tag (safe core schema —
33
- * config data is never executable), or a `ConfigValidationError` from core
34
- * `defineConfig()` (re-thrown with file-path context, keeping the error type).
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).
35
36
  *
36
37
  * Before returning, the discovered `configPath` is appended to
37
38
  * `config.protectedPaths` unless already present — the config file itself joins the
@@ -53,7 +54,13 @@ export function loadConfig(rootDir) {
53
54
  const document = parseDocument(source);
54
55
  const problems = [...document.errors, ...document.warnings];
55
56
  if (problems.length > 0) {
56
- throw new Error(`failed to parse ${configPath}: ${problems[0].message}`);
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}`);
57
64
  }
58
65
  const parsed = document.toJS();
59
66
  // Strip the IDE `$schema` reference before delegating — the loader owns no
@@ -25,13 +25,4 @@ 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` (§3-i),
30
- * skipping whatever is already there.
31
- *
32
- * Throws when two or more config spellings coexist (§3-a third disposition) — that tree is
33
- * already stopped, since {@link loadConfig} refuses an ambiguous discovery, and adding
34
- * artifacts to it would wire a judge whose every call fails closed. The throw lands before
35
- * any write, so a human deleting one config is all it takes to reopen the path.
36
- */
37
28
  export declare function scaffoldProject(projectRoot: string): ScaffoldReport;
@@ -27,8 +27,10 @@ const GITIGNORE_ENTRY = `# Polydeukes telemetry — local observation data, neve
27
27
  * The generated config: the §3-d minimum protection set and the §3-e witness block, both
28
28
  * mandatory. Emitted as a literal template rather than serialized from an object because
29
29
  * the comments ARE the artifact — a consumer's first contact with the protection surface is
30
- * reading why each entry is on it. No `$schema` line: the consumer-side spelling of that
31
- * path is unmeasured (§2-c), and a wrong one loses editor validation silently.
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).
32
34
  */
33
35
  const GENERATED_CONFIG = `# Polydeukes protection policy — generated by \`pdks init claude-code\`.
34
36
  #
@@ -53,15 +55,9 @@ languages:
53
55
  # layer that can watch it happen.
54
56
  #
55
57
  # A minimum. Add entries as you find you want them.
56
- # node_modules, .claude/node_modules — the resolution path. The generated hook loads the
57
- # judge by package NAME, so every directory Node's ancestor walk can answer from decides
58
- # which code judges this session. A stub planted on that walk replaces the judge outright
59
- # and every call then passes with no telemetry row at all.
60
58
  protectedPaths:
61
59
  - '.claude/hooks'
62
60
  - '.claude/settings.json'
63
- - 'node_modules'
64
- - '.claude/node_modules'
65
61
 
66
62
  # The time-boxed witness — the human valve on a blocked verdict. A human types this token so
67
63
  # it stands alone on a message's FIRST line, the window holds for ttlMinutes, then blocking
@@ -85,6 +81,24 @@ witness:
85
81
  * artifacts to it would wire a judge whose every call fails closed. The throw lands before
86
82
  * any write, so a human deleting one config is all it takes to reopen the path.
87
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
+ }
88
102
  export function scaffoldProject(projectRoot) {
89
103
  const report = { created: [], skipped: [] };
90
104
  const found = CONFIG_FILENAMES.filter((name) => existsSync(join(projectRoot, name)));
@@ -96,7 +110,7 @@ export function scaffoldProject(projectRoot) {
96
110
  report.skipped.push(found[0]);
97
111
  }
98
112
  else {
99
- writeFileSync(join(projectRoot, CONFIG_FILENAMES[0]), GENERATED_CONFIG);
113
+ writeFileSync(join(projectRoot, CONFIG_FILENAMES[0]), schemaDirective(projectRoot) + GENERATED_CONFIG);
100
114
  report.created.push(CONFIG_FILENAMES[0]);
101
115
  }
102
116
  // The entry is judged as a whole line. A substring test would treat a commented-out line