@polydeukes/adapter-claude-code 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +19 -11
- package/README.md +19 -11
- package/dist/bin.d.ts +9 -0
- package/dist/bin.js +30 -0
- package/dist/hook.d.ts +51 -0
- package/dist/hook.js +142 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/init.d.ts +57 -0
- package/dist/init.js +594 -0
- package/dist/resolve-umbrella.d.ts +24 -0
- package/dist/resolve-umbrella.js +52 -0
- package/dist/session-evidence.d.ts +34 -0
- package/dist/session-evidence.js +46 -0
- package/dist/session-vocabulary.d.ts +1 -1
- package/dist/session-vocabulary.js +1 -1
- package/package.json +10 -5
- package/dist/run-adapter-path.d.ts +0 -53
- package/dist/run-adapter-path.js +0 -88
package/dist/init.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `initClaudeCode` — the Claude Code session-surface installer.
|
|
3
|
+
*
|
|
4
|
+
* One command wires a project into the session surface: prove `polydeukes` resolves, spawn
|
|
5
|
+
* its agent-neutral scaffold (`pdks init`, which writes the config and the telemetry ignore
|
|
6
|
+
* line), then add what this package owns — the delegator hook file, its
|
|
7
|
+
* `.claude/settings.json` registration, the discipline file that tells an agent the docs
|
|
8
|
+
* query exists, and the classification skill that turns a described problem into a config
|
|
9
|
+
* entry.
|
|
10
|
+
*
|
|
11
|
+
* Preflight comes first and nothing is written before it clears. A generated hook whose
|
|
12
|
+
* import can never resolve blocks every call through its own fail-closed catch, and a tree
|
|
13
|
+
* that also has no config and no valve to open cannot be edited back into shape from inside
|
|
14
|
+
* the session.
|
|
15
|
+
*
|
|
16
|
+
* Nothing existing is overwritten. The settings file in particular is merged, never
|
|
17
|
+
* replaced: a consumer's other PreToolUse registrations and permissions are live
|
|
18
|
+
* configuration, and replacing them would disarm every other tool they wired.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { isPlainObject } from '@polydeukes/core';
|
|
24
|
+
import { findUmbrellaBin, UMBRELLA_PACKAGE } from './resolve-umbrella.js';
|
|
25
|
+
import { MUTATING_TOOLS, SHELL_TOOLS } from './session-vocabulary.js';
|
|
26
|
+
/** The package the generated delegator loads `runHook` from. */
|
|
27
|
+
const ADAPTER_SPECIFIER = '@polydeukes/adapter-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
|
+
const SKILL_RELATIVE = '.claude/skills/discipline-draft/SKILL.md';
|
|
33
|
+
/**
|
|
34
|
+
* The command the host spawns, and the string our registration is recognized by: the same
|
|
35
|
+
* command already present means already registered. A registration keyed on anything else
|
|
36
|
+
* would be re-added on every run, and the host would then spawn the judge twice per call —
|
|
37
|
+
* every verdict and every telemetry row doubled.
|
|
38
|
+
*/
|
|
39
|
+
const HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR"/${HOOK_RELATIVE}`;
|
|
40
|
+
/** Which calls reach the judge — this package's own vocabulary, never a copy of it. */
|
|
41
|
+
const HOOK_MATCHER = [...MUTATING_TOOLS, ...SHELL_TOOLS].join('|');
|
|
42
|
+
/** The umbrella's docs topics — a copy of its `DOCS_TOPICS` tuple, pinned equal by test. */
|
|
43
|
+
const DOCS_TOPICS = ['install', 'config', 'discipline', 'covenant', 'witness'];
|
|
44
|
+
/** What a session is about to do per topic — the correspondence the generated file carries. */
|
|
45
|
+
const DOCS_TOPIC_PURPOSE = {
|
|
46
|
+
install: 'install Polydeukes, or wire another surface into this project',
|
|
47
|
+
config: 'edit `polydeukes.config.*` — every key and what reads it',
|
|
48
|
+
discipline: 'add or change a `disciplines` entry',
|
|
49
|
+
covenant: 'explain a verdict, or why a surface failed closed',
|
|
50
|
+
witness: 'open a blocked call in person',
|
|
51
|
+
};
|
|
52
|
+
/** The umbrella's config spellings — a copy of its `CONFIG_FILENAMES` tuple, pinned equal by test. */
|
|
53
|
+
const CONFIG_FILENAMES = [
|
|
54
|
+
'polydeukes.config.yaml',
|
|
55
|
+
'polydeukes.config.yml',
|
|
56
|
+
'polydeukes.config.json',
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* The generated hook. It carries no assembly at all, so upgrading the package upgrades the
|
|
60
|
+
* judge without regenerating this file.
|
|
61
|
+
*/
|
|
62
|
+
const GENERATED_HOOK = `#!/usr/bin/env node
|
|
63
|
+
/**
|
|
64
|
+
* Polydeukes PreToolUse covenant hook — generated by \`pdks-claude-code init\`.
|
|
65
|
+
*
|
|
66
|
+
* A delegator and nothing more: \`runHook\` builds the covenant input from this payload and
|
|
67
|
+
* spawns \`pdks covenant check\`, so the judgment lives in the installed packages and this
|
|
68
|
+
* file never needs regenerating.
|
|
69
|
+
*
|
|
70
|
+
* \`repoRoot\` comes from this file's own location, never from the working directory. A hook
|
|
71
|
+
* is spawned with whatever directory the agent happened to hold, and what config discovery
|
|
72
|
+
* and the package lookup need is the project that CONTAINS this hook — always \`../..\`
|
|
73
|
+
* from here.
|
|
74
|
+
*
|
|
75
|
+
* fail-closed: \`runHook\` translates every failure it can reach into exit 2, with the row
|
|
76
|
+
* written by the judge it spawns. This catch answers only for what it cannot reach — the
|
|
77
|
+
* package failing to resolve or load at all (never installed, or installed without a
|
|
78
|
+
* build) — where no telemetry writer exists yet. Recovery is installing the package again.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import { dirname, join } from 'node:path';
|
|
82
|
+
import { fileURLToPath } from 'node:url';
|
|
83
|
+
|
|
84
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const { runHook } = await import('${ADAPTER_SPECIFIER}');
|
|
88
|
+
const { exitCode } = runHook({ repoRoot });
|
|
89
|
+
// Assign and let the process end naturally instead of process.exit(): an explicit exit
|
|
90
|
+
// can preempt a buffered stderr write on platforms with async pipes, dropping the break
|
|
91
|
+
// reason the agent needs to read.
|
|
92
|
+
process.exitCode = exitCode;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(\`covenant hook failed closed: \${error?.message ?? error}\`);
|
|
95
|
+
process.exitCode = 2;
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
/**
|
|
99
|
+
* The generated discipline file — the discovery path that gets the query surface called. A
|
|
100
|
+
* query an agent never learns about is a query that does not exist. One scoped file costs
|
|
101
|
+
* nothing while it waits: `paths` frontmatter keeps it out of context until a Polydeukes
|
|
102
|
+
* path is in play.
|
|
103
|
+
*
|
|
104
|
+
* The topic names and config spellings are literals here and belong to the umbrella's
|
|
105
|
+
* surface; a file naming a query that exits 2 fails the agent once, and it never calls the
|
|
106
|
+
* command again.
|
|
107
|
+
*/
|
|
108
|
+
const GENERATED_DISCOVERY = `---
|
|
109
|
+
paths:
|
|
110
|
+
${CONFIG_FILENAMES.map((name) => ` - "${name}"`).join('\n')}
|
|
111
|
+
- ".claude/**"
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# Polydeukes — query the installed docs
|
|
115
|
+
|
|
116
|
+
This project is judged by Polydeukes, and the matching documentation ships inside the
|
|
117
|
+
installed package. \`pdks docs\` answers offline, from the same version that does the
|
|
118
|
+
judging; a web search answers from whichever release it indexed.
|
|
119
|
+
|
|
120
|
+
Run \`pdks docs\` for the topic list, \`pdks docs <topic>\` for topic content,
|
|
121
|
+
\`pdks docs search "locale key pairing"\` to find a section, or
|
|
122
|
+
\`pdks docs show write-disciplines\` to retrieve the guide. Add \`--lang ko\` for Korean.
|
|
123
|
+
|
|
124
|
+
A local install puts the bin in \`node_modules/.bin\`, which a plain shell does not have on
|
|
125
|
+
PATH. If \`pdks\` is not found, run \`./node_modules/.bin/pdks docs <topic>\` — or your package
|
|
126
|
+
manager's exec form — from the project root.
|
|
127
|
+
|
|
128
|
+
| Before you | Run |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
${Object.entries(DOCS_TOPIC_PURPOSE)
|
|
131
|
+
.map(([topic, purpose]) => `| ${purpose} | \`pdks docs ${topic}\` |`)
|
|
132
|
+
.join('\n')}
|
|
133
|
+
`;
|
|
134
|
+
/**
|
|
135
|
+
* The generated classification skill — the procedure that turns a described problem into a
|
|
136
|
+
* registered entry. A classification procedure an agent never learns about is one that never
|
|
137
|
+
* runs, so it ships as an artifact of the install rather than as prose in a README.
|
|
138
|
+
*
|
|
139
|
+
* Its advise-consumption section is the delivery path for advised rows: the session surface
|
|
140
|
+
* lets an advised call through with exit 0, and the reason never reaches the model at call
|
|
141
|
+
* time — reading the telemetry log at task boundaries is the only way it arrives.
|
|
142
|
+
*/
|
|
143
|
+
const GENERATED_SKILL = `---
|
|
144
|
+
name: discipline-draft
|
|
145
|
+
description: Turn a described discipline problem into a registered entry in polydeukes.config — a judged entry when the declaration grammar and observed evidence 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").
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
# discipline-draft — from a problem description to a registered discipline
|
|
149
|
+
|
|
150
|
+
This project is judged by Polydeukes. A discipline starts as prose and climbs a ladder —
|
|
151
|
+
\`draft\` (registered, read, never judged) → \`advise\` (judged, recorded, never stops a call) →
|
|
152
|
+
\`block\` (stops the call; the user's explicit choice, never the default). This skill walks a
|
|
153
|
+
problem description down to the right first rung and registers it.
|
|
154
|
+
|
|
155
|
+
## Procedure
|
|
156
|
+
|
|
157
|
+
### 1. Restate the problem as a promise
|
|
158
|
+
|
|
159
|
+
Rewrite the description as one sentence of the form "X must not happen" or "when A happens,
|
|
160
|
+
B must also happen". If the sentence needs "unless" more than once, split it into two
|
|
161
|
+
promises and classify each separately.
|
|
162
|
+
|
|
163
|
+
### 2. Classify the shape
|
|
164
|
+
|
|
165
|
+
Choose from the current catalogue, then check whether the intended surface can supply the
|
|
166
|
+
required evidence. A mechanism name constrains the declaration; it does not implement the
|
|
167
|
+
promise by itself. The extracted axes and body relations must be subsets of the admitted
|
|
168
|
+
sets below. Scope filtering is separate from the extracted axes.
|
|
169
|
+
|
|
170
|
+
| Mechanism | Admitted axes | Body relations | Evidence or structural condition |
|
|
171
|
+
| --- | --- | --- | --- |
|
|
172
|
+
| \`pairing\` | \`world\` | \`equal\` | Compare supplied files or channels; extract keys when values may differ. |
|
|
173
|
+
| \`companion\` | \`change\`, \`world\` | \`implies\` | Compare presence by key; a multi-file promise needs the observed change set. |
|
|
174
|
+
| \`monotonic-order\` | \`change\`, \`world\` | \`ordered\` | Extract a sequence with an explicit comparison field; order is not presence. |
|
|
175
|
+
| \`fingerprint-sync\` | \`world\` | \`equal\` | Compare supplied stamps; no generator or compiler runs during judgment. |
|
|
176
|
+
| \`producer-owned\` | \`actor\` | \`empty\`, \`nonEmpty\` | Requires host-provided actor evidence, not an artifact's self-reported producer. |
|
|
177
|
+
| \`self-absolution-ban\` | \`change\` | \`unchanged\`, \`empty\` | Extract protected fields or path changes; choose creation/deletion supply explicitly. |
|
|
178
|
+
| \`actor-scope\` | \`actor\` | \`empty\`, \`nonEmpty\` | Requires a proven actor; a missing actor is not proof of the main session. |
|
|
179
|
+
| \`precedent\` | \`history\`, \`world\` | \`nonEmpty\` | Requires an observed earlier call in a transcript or supplied channel. |
|
|
180
|
+
| \`phase-order\` | \`history\` | \`ordered\` | Compare observed call ordinals; missing phases need a separate presence promise. |
|
|
181
|
+
| \`turn-locality\` | \`history\` | \`nonEmpty\` | Requires observed turns and time or ordinal boundaries. |
|
|
182
|
+
| \`stated-ground\` | \`history\` | \`nonEmpty\` | Can require recorded text, not establish whether its reasoning is sound. |
|
|
183
|
+
| \`controlled-vocabulary\` | \`change\`, \`world\` | \`subset\` | Extract values and an explicit allowed set. |
|
|
184
|
+
| \`naming\` | \`change\` | \`empty\`, \`nonEmpty\` | Scope must read \`target.path\`; match the intended name pattern. |
|
|
185
|
+
| \`added-only\` | \`change\` | \`empty\` | Compare pre/post extractions and judge only newly added matches. |
|
|
186
|
+
| \`one-way-marker\` | \`change\` | \`subset\` | Existing markers must remain in the extracted post-change set. |
|
|
187
|
+
| \`delegated-scope\` | — | — | Reserved for a definition-time evaluator; not accepted in current declarations. |
|
|
188
|
+
| \`scoped-valve\` | \`change\`, \`actor\`, \`world\`, \`history\` | \`empty\`, \`nonEmpty\`, \`equal\`, \`subset\`, \`implies\`, \`ordered\`, \`unchanged\` | Requires a \`witness\` block expressing the exception condition. |
|
|
189
|
+
| \`forbidden-command\` | \`change\` | \`empty\` | Scope must read \`command\`; a text pattern is not shell semantic analysis. |
|
|
190
|
+
|
|
191
|
+
These four requests illustrate the classification boundary:
|
|
192
|
+
|
|
193
|
+
| Request | Classification | Proof |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| The English and Korean locale files must carry identical keys. | \`pairing\`, with two supplied files. | An unmatched key breaks; translated values may differ. |
|
|
196
|
+
| Every status must belong to an allowed list. | \`controlled-vocabulary\`, with a supplied allowed set. | An unknown status breaks; an allowed status passes. |
|
|
197
|
+
| A successful package lookup must precede a manifest edit. | \`precedent\`, with observed session history. | Failed or absent lookups break; an unavailable transcript is a supply case. |
|
|
198
|
+
| A fresh benchmark must execute during judgment to prove a performance claim. | \`draft\`: the engine does not execute benchmarks. | Comparing an existing report would be a different promise. |
|
|
199
|
+
|
|
200
|
+
Run \`pdks docs show write-disciplines\` for the key-pairing walkthrough and
|
|
201
|
+
\`pdks docs show configuration --section disciplines\` for the declaration grammar.
|
|
202
|
+
Use \`--lang ko\` for Korean; these commands read the installed version offline.
|
|
203
|
+
|
|
204
|
+
An \`added-only\` declaration forgives existing occurrences — only what the edit adds breaks
|
|
205
|
+
the promise. That is usually what you want: a discipline adopted today should not indict
|
|
206
|
+
yesterday's code.
|
|
207
|
+
|
|
208
|
+
One path-shaped promise takes no \`disciplines:\` entry at all: a path nobody may touch
|
|
209
|
+
belongs in the top-level \`protectedPaths:\` list — its own config block, never an entry key.
|
|
210
|
+
|
|
211
|
+
### 3. Check the observation boundary
|
|
212
|
+
|
|
213
|
+
Do not confuse an expressible relation with available evidence:
|
|
214
|
+
|
|
215
|
+
- **Files outside the repository** — file-change protection observes the project root.
|
|
216
|
+
Use the host's permission policy for comprehensive protection outside it. A command-text
|
|
217
|
+
pattern may recognize a particular string, but does not observe all resulting writes.
|
|
218
|
+
- **Writes by child processes** — arbitrary writes inside a test runner or script are not
|
|
219
|
+
individually observed by the session surface. A commit comparison can observe the resulting
|
|
220
|
+
files when they enter its selected diff; it does not recover the originating tool history.
|
|
221
|
+
- **Missing history or actor channels** — choose the declaration's supply policy explicitly.
|
|
222
|
+
Commit observations have no session transcript; \`supply: pass\` records a skip, not success.
|
|
223
|
+
- **Fresh execution or semantic proof** — the engine compares supplied evidence. It does not
|
|
224
|
+
run a new benchmark or prove that a written explanation is true. Preserve that unmet promise
|
|
225
|
+
as a draft rather than silently replacing it with a weaker text check.
|
|
226
|
+
|
|
227
|
+
### 4a. Expressible now — register a judged entry
|
|
228
|
+
|
|
229
|
+
Add the entry to the \`disciplines:\` array in \`polydeukes.config.yaml\`. Advise is the default
|
|
230
|
+
landing — a break is recorded as \`advised\` and the call goes on — and the \`enforce: advise\`
|
|
231
|
+
line below only spells that default out. NEVER write \`enforce: block\` from this skill:
|
|
232
|
+
promotion to block is the user's own choice, made after the advise measurements have been
|
|
233
|
+
read.
|
|
234
|
+
|
|
235
|
+
The examples below are whole documents, so \`languages:\` — the schema's one required block —
|
|
236
|
+
appears alongside the entry; in a config that already has one, copy the entry only.
|
|
237
|
+
|
|
238
|
+
\`\`\`yaml
|
|
239
|
+
languages:
|
|
240
|
+
placeholder:
|
|
241
|
+
productionGlob: 'src/**'
|
|
242
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
243
|
+
disciplines:
|
|
244
|
+
- id: 'no-focused-tests'
|
|
245
|
+
why: 'a committed .only silently shrinks the suite to one test'
|
|
246
|
+
declare:
|
|
247
|
+
mechanism: 'added-only'
|
|
248
|
+
scope: { source: 'target.path', include: ['^src/'] }
|
|
249
|
+
supply: { pre: 'empty', post: 'empty' }
|
|
250
|
+
extract:
|
|
251
|
+
before:
|
|
252
|
+
- { op: 'source', of: 'pre' }
|
|
253
|
+
- { op: 'lines' }
|
|
254
|
+
- { op: 'keyByPattern', re: '(\\.only\\()' }
|
|
255
|
+
after:
|
|
256
|
+
- { op: 'source', of: 'post' }
|
|
257
|
+
- { op: 'lines' }
|
|
258
|
+
- { op: 'keyByPattern', re: '(\\.only\\()' }
|
|
259
|
+
added:
|
|
260
|
+
- { op: 'onlyIn', of: 'after', notIn: 'before' }
|
|
261
|
+
relate:
|
|
262
|
+
- id: 'nothing-added'
|
|
263
|
+
relation: { op: 'empty', of: 'added' }
|
|
264
|
+
message: 'adds {key}: {value}'
|
|
265
|
+
enforce: advise
|
|
266
|
+
\`\`\`
|
|
267
|
+
|
|
268
|
+
A command-line ban reads the fixed source \`command\` and scopes on it — the scope is part of
|
|
269
|
+
the mechanism's shape, so a \`forbidden-command\` entry without it is refused at load time:
|
|
270
|
+
|
|
271
|
+
\`\`\`yaml
|
|
272
|
+
languages:
|
|
273
|
+
placeholder:
|
|
274
|
+
productionGlob: 'src/**'
|
|
275
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
276
|
+
sessionDisciplines:
|
|
277
|
+
- id: 'no-force-push'
|
|
278
|
+
why: 'a force push rewrites history nobody reviewed'
|
|
279
|
+
declare:
|
|
280
|
+
mechanism: 'forbidden-command'
|
|
281
|
+
scope: { source: 'command' }
|
|
282
|
+
extract:
|
|
283
|
+
hits:
|
|
284
|
+
- { op: 'source', of: 'command' }
|
|
285
|
+
- { op: 'lines' }
|
|
286
|
+
- { op: 'matches', re: 'git push\\b.*--force(?![\\w-])' }
|
|
287
|
+
relate:
|
|
288
|
+
- { id: 'no-force', relation: { op: 'empty', of: 'hits' }, message: '{value}' }
|
|
289
|
+
enforce: advise
|
|
290
|
+
\`\`\`
|
|
291
|
+
|
|
292
|
+
The following examples implement the first three classification cases. Both locale files and
|
|
293
|
+
the allowed-status file must exist and contain valid JSON. File bindings use the proposed
|
|
294
|
+
contents for a file changed by the current observation, not a second stale disk read.
|
|
295
|
+
|
|
296
|
+
\`\`\`yaml
|
|
297
|
+
languages:
|
|
298
|
+
json:
|
|
299
|
+
productionGlob: 'locales/**/*.json'
|
|
300
|
+
testCmd: 'pnpm test'
|
|
301
|
+
disciplines:
|
|
302
|
+
- id: 'locale-key-parity'
|
|
303
|
+
why: 'the ko and en locales must carry the same keys'
|
|
304
|
+
declare:
|
|
305
|
+
mechanism: 'pairing'
|
|
306
|
+
scope: { source: 'target.path', include: ['^locales/(ko|en)[.]json$'] }
|
|
307
|
+
sources:
|
|
308
|
+
ko: { file: 'locales/ko.json' }
|
|
309
|
+
en: { file: 'locales/en.json' }
|
|
310
|
+
supply: { ko: 'error', en: 'error' }
|
|
311
|
+
extract:
|
|
312
|
+
koKeys: [{ op: 'source', of: 'ko' }, { op: 'json' }, { op: 'flattenKeys' }]
|
|
313
|
+
enKeys: [{ op: 'source', of: 'en' }, { op: 'json' }, { op: 'flattenKeys' }]
|
|
314
|
+
relate:
|
|
315
|
+
- id: 'parity'
|
|
316
|
+
relation: { op: 'equal', of: ['koKeys', 'enKeys'] }
|
|
317
|
+
messageBySide:
|
|
318
|
+
left: '{key} is in ko only'
|
|
319
|
+
right: '{key} is in en only'
|
|
320
|
+
enforce: advise
|
|
321
|
+
\`\`\`
|
|
322
|
+
|
|
323
|
+
\`\`\`yaml
|
|
324
|
+
languages:
|
|
325
|
+
json:
|
|
326
|
+
productionGlob: '*.json'
|
|
327
|
+
testCmd: 'pnpm test'
|
|
328
|
+
disciplines:
|
|
329
|
+
- id: 'status-vocabulary'
|
|
330
|
+
why: 'statuses.json may contain only values listed in allowed-statuses.json'
|
|
331
|
+
declare:
|
|
332
|
+
mechanism: 'controlled-vocabulary'
|
|
333
|
+
scope: { source: 'target.path', include: ['^statuses[.]json$'] }
|
|
334
|
+
sources: { allowed: { file: 'allowed-statuses.json' } }
|
|
335
|
+
supply: { post: 'error', allowed: 'error' }
|
|
336
|
+
extract:
|
|
337
|
+
selected: [{ op: 'source', of: 'post' }, { op: 'json' }, { op: 'items' }]
|
|
338
|
+
permitted: [{ op: 'source', of: 'allowed' }, { op: 'json' }, { op: 'items' }]
|
|
339
|
+
relate:
|
|
340
|
+
- id: 'allowed-status'
|
|
341
|
+
relation: { op: 'subset', of: 'selected', in: 'permitted' }
|
|
342
|
+
message: 'unknown status: {value}'
|
|
343
|
+
enforce: advise
|
|
344
|
+
\`\`\`
|
|
345
|
+
|
|
346
|
+
Here both status files are JSON arrays of strings. This declaration scopes on statuses.json;
|
|
347
|
+
editing only the allowed list does not trigger it. Broaden the observation deliberately if
|
|
348
|
+
changes to that list must recheck all dependent files.
|
|
349
|
+
|
|
350
|
+
\`\`\`yaml
|
|
351
|
+
languages:
|
|
352
|
+
typescript:
|
|
353
|
+
productionGlob: 'src/**'
|
|
354
|
+
testCmd: 'pnpm test'
|
|
355
|
+
sessionDisciplines:
|
|
356
|
+
- id: 'manifest-needs-npm-view'
|
|
357
|
+
why: 'a successful package lookup must precede a manifest edit'
|
|
358
|
+
declare:
|
|
359
|
+
mechanism: 'precedent'
|
|
360
|
+
scope: { source: 'target.path', include: ['^(packages/[^/]+/)?package[.]json$'] }
|
|
361
|
+
sources: { session: { transcript: true } }
|
|
362
|
+
supply: { session: 'pass' }
|
|
363
|
+
extract:
|
|
364
|
+
npmView:
|
|
365
|
+
- { op: 'source', of: 'session' }
|
|
366
|
+
- { op: 'toolUses', names: ['Bash'] }
|
|
367
|
+
- { op: 'filter', when: [{ field: 'succeeded', eq: true }] }
|
|
368
|
+
- { op: 'select', path: 'args.command' }
|
|
369
|
+
- { op: 'matches', re: '^npm view ' }
|
|
370
|
+
relate:
|
|
371
|
+
- id: 'npm-view'
|
|
372
|
+
relation: { op: 'nonEmpty', of: 'npmView' }
|
|
373
|
+
message: 'no successful npm view precedes this edit'
|
|
374
|
+
enforce: advise
|
|
375
|
+
\`\`\`
|
|
376
|
+
|
|
377
|
+
The precedent example proves only that an observed successful Bash call starts with npm view;
|
|
378
|
+
it does not prove that the lookup concerns the dependency being edited. The change-set surface has
|
|
379
|
+
no transcript and therefore skips this example by its explicit supply policy.
|
|
380
|
+
|
|
381
|
+
**Write the regex yourself — the user states the promise, you author the pattern.** The
|
|
382
|
+
pattern is the part users find hardest, so never hand the prose back and ask for one. Three
|
|
383
|
+
authoring traps, each measured on a live config:
|
|
384
|
+
|
|
385
|
+
- **A pattern answers a syntactic question only.** "Is this string a forbidden word" is
|
|
386
|
+
syntax; "is this a new dependency version" is meaning, and a regex leaks both ways on a
|
|
387
|
+
semantic question. When the question is semantic, narrow the declaration's own \`scope\`
|
|
388
|
+
block to the files where any match IS a break, or accept "editing this file at all" as
|
|
389
|
+
the trigger.
|
|
390
|
+
- **\`^\` means what the preceding step left.** After a \`lines\` step a declaration's
|
|
391
|
+
pattern sees one line at a time, so \`^\` anchors to that line; over an unsplit source it
|
|
392
|
+
anchors to the whole text and matches the first line only. A ban over the command line
|
|
393
|
+
puts \`lines\` before its \`matches\` for exactly that reason.
|
|
394
|
+
- **Author both directions.** Before registering, write down one string the pattern must
|
|
395
|
+
match and one nearby string it must not (\`only(\` vs \`only_helper(\`, a flag vs its
|
|
396
|
+
substring). A pattern checked in only the breaking direction over-fires in review-proof
|
|
397
|
+
ways.
|
|
398
|
+
|
|
399
|
+
### 4b. Not expressible yet — register a draft
|
|
400
|
+
|
|
401
|
+
A draft is prose with a handle: \`id\`, \`why\`, and the literal marker \`draft: true\` — no other
|
|
402
|
+
keys. It produces no judgment and no telemetry; \`pdks explain\` lists it as unpromoted.
|
|
403
|
+
Record the intended promise and the exact missing capability inside \`why\`. Do not classify
|
|
404
|
+
pairing, vocabulary, or history promises as drafts merely because they are absent from a short
|
|
405
|
+
example list. Check the catalogue, extraction steps, and observation channel first. A reserved
|
|
406
|
+
\`delegated-scope\` declaration cannot be registered as a judged entry.
|
|
407
|
+
|
|
408
|
+
\`\`\`yaml
|
|
409
|
+
languages:
|
|
410
|
+
placeholder:
|
|
411
|
+
productionGlob: 'src/**'
|
|
412
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
413
|
+
disciplines:
|
|
414
|
+
- id: 'benchmark-supports-performance-claim'
|
|
415
|
+
why: 'a performance claim needs a fresh benchmark run during judgment; the engine cannot execute it'
|
|
416
|
+
draft: true
|
|
417
|
+
\`\`\`
|
|
418
|
+
|
|
419
|
+
### 5. Prove it fires, then close
|
|
420
|
+
|
|
421
|
+
Run \`pdks explain\` and confirm the new entry is listed (a judged entry with its mechanism
|
|
422
|
+
and surfaces; a draft as unpromoted).
|
|
423
|
+
|
|
424
|
+
For a judged entry, registration is not the finish — a pattern that never fires protects
|
|
425
|
+
nothing while looking installed. Fire it once for real, with the proof run the declaration's
|
|
426
|
+
own mechanism can actually reach:
|
|
427
|
+
|
|
428
|
+
| Mechanism | Break it once | The entry's id shows up in |
|
|
429
|
+
| --- | --- | --- |
|
|
430
|
+
| a file-reading one (\`added-only\`, \`naming\`, …) | one scratch edit matching the must-match direction | \`pdks covenant check --diff\` output over \`git diff HEAD\` on stdin — the exit stays 0 at advise, the id is the proof |
|
|
431
|
+
| \`forbidden-command\` | run one harmless command matching the pattern | the telemetry log tail — at advise the call proceeds and its row records the id |
|
|
432
|
+
| \`precedent\` | one in-scope edit made without the required precedent | the telemetry log tail — a declaration reading the session judges on the session surface only (the change-set surface has none, so its \`supply\` policy records it \`skipped\`) |
|
|
433
|
+
|
|
434
|
+
Then undo the scratch break, repeat the same observation, and confirm a passing row for the
|
|
435
|
+
must-NOT-match case. Silence alone may mean a scope miss, unchanged files, or unavailable evidence;
|
|
436
|
+
check \`pdks explain\` and telemetry for \`config-fault\`, \`no-observation\`, or \`supply-pass\`. Close by telling the user which rung the entry landed on and
|
|
437
|
+
that \`enforce: block\` is theirs to add later if the advise record earns it.
|
|
438
|
+
|
|
439
|
+
## Updating this skill without losing local edits
|
|
440
|
+
|
|
441
|
+
An upgrade does not overwrite an existing skill; rerunning \`pdks-claude-code init\` reports it
|
|
442
|
+
skipped. Generate a fresh copy in a disposable project using the installed package, compare it
|
|
443
|
+
with this file, and merge the changes you want. Keep a backup of local additions. Do not delete
|
|
444
|
+
the existing skill to force regeneration in the working project.
|
|
445
|
+
|
|
446
|
+
## Reading the advise record
|
|
447
|
+
|
|
448
|
+
An \`advised\` row means a promise was broken and the call went through anyway. Rows land in
|
|
449
|
+
the telemetry log at the path configured by \`telemetry.logPath\` (default
|
|
450
|
+
\`.polydeukes/roi.log\`). The hook's stderr note is not shown to you, so consult the log at
|
|
451
|
+
task boundaries: before committing, or after a batch of edits, read the tail and act on any
|
|
452
|
+
\`advised\` row — fix the break, or tell the user why it should stand. An advisory nobody
|
|
453
|
+
reads measures nothing.
|
|
454
|
+
`;
|
|
455
|
+
/** The default preflight: where is `polydeukes` installed, as reachable from `projectRoot`? */
|
|
456
|
+
function resolveFromProjectRoot(projectRoot) {
|
|
457
|
+
const bin = findUmbrellaBin(projectRoot);
|
|
458
|
+
if (bin === undefined) {
|
|
459
|
+
throw new Error(`${UMBRELLA_PACKAGE} is not installed where this project can reach it`);
|
|
460
|
+
}
|
|
461
|
+
return bin;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Is our PreToolUse command already in this settings object? Asked twice — before the merge
|
|
465
|
+
* to stay idempotent, and after the write to prove the file took it.
|
|
466
|
+
*/
|
|
467
|
+
function carriesRegistration(settings) {
|
|
468
|
+
return (settings.hooks?.PreToolUse ?? []).some((entry) => (entry?.hooks ?? []).some((hook) => hook?.command === HOOK_COMMAND));
|
|
469
|
+
}
|
|
470
|
+
/** Write one generated artifact unless it is already there, recording which happened. */
|
|
471
|
+
function writeIfAbsent(projectRoot, relative, contents, report) {
|
|
472
|
+
const path = join(projectRoot, relative);
|
|
473
|
+
if (existsSync(path)) {
|
|
474
|
+
report.skipped.push(relative);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
478
|
+
writeFileSync(path, contents);
|
|
479
|
+
report.created.push(relative);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Read the settings file, or an empty one when there is none yet. Belongs to preflight
|
|
483
|
+
* rather than to the merge: parsing it later would leave a wired-but-unregistered tree —
|
|
484
|
+
* delegator on disk, host never told to spawn it, every call unjudged with no telemetry row.
|
|
485
|
+
*/
|
|
486
|
+
function readSettings(projectRoot) {
|
|
487
|
+
const settingsPath = join(projectRoot, SETTINGS_RELATIVE);
|
|
488
|
+
if (!existsSync(settingsPath)) {
|
|
489
|
+
return {};
|
|
490
|
+
}
|
|
491
|
+
let parsed;
|
|
492
|
+
try {
|
|
493
|
+
parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
throw new Error(`cannot parse ${SETTINGS_RELATIVE} in ${projectRoot} — fix it and re-run ` +
|
|
497
|
+
`(${error instanceof Error ? error.message : String(error)})`);
|
|
498
|
+
}
|
|
499
|
+
// Only a plain object can carry a `hooks` key. An array root takes the assignment as a
|
|
500
|
+
// non-index property that JSON.stringify discards, and a null root throws inside the merge
|
|
501
|
+
// — both after the scaffold and the delegator have already landed. Refusing here keeps
|
|
502
|
+
// every precondition failure at zero files.
|
|
503
|
+
if (!isPlainObject(parsed)) {
|
|
504
|
+
throw new Error(`${SETTINGS_RELATIVE} in ${projectRoot} is not a JSON object — fix it and re-run`);
|
|
505
|
+
}
|
|
506
|
+
return parsed;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Add the PreToolUse registration to `.claude/settings.json`, keeping everything else the
|
|
510
|
+
* file carries — other registrations, other hook events, and unrelated keys alike. A
|
|
511
|
+
* settings file with no `hooks` key at all is the commonest consumer state (it exists for
|
|
512
|
+
* permissions alone), so the nesting is created here rather than assumed.
|
|
513
|
+
*/
|
|
514
|
+
function mergeSettings(projectRoot, settings, report) {
|
|
515
|
+
const settingsPath = join(projectRoot, SETTINGS_RELATIVE);
|
|
516
|
+
const preToolUse = settings.hooks?.PreToolUse ?? [];
|
|
517
|
+
if (carriesRegistration(settings)) {
|
|
518
|
+
// Not rewriting is the point: a re-serialization would rewrite a consumer's formatting
|
|
519
|
+
// on every run, which is an overwrite by another name.
|
|
520
|
+
report.skipped.push(SETTINGS_RELATIVE);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
settings.hooks = {
|
|
524
|
+
...settings.hooks,
|
|
525
|
+
PreToolUse: [
|
|
526
|
+
...preToolUse,
|
|
527
|
+
{ matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_COMMAND }] },
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
531
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
532
|
+
// Read the registration back rather than assuming the write carried it. The one outcome
|
|
533
|
+
// this installer must never produce is a successful-looking run whose judge never spawns,
|
|
534
|
+
// and the merge can drop the entry without failing — a settings file whose root is an
|
|
535
|
+
// array takes the assignment as a non-index property and `JSON.stringify` discards it.
|
|
536
|
+
// Checking the file instead of the shapes that reach it keeps the question finite: one
|
|
537
|
+
// code path, asked after every write, whatever arrived.
|
|
538
|
+
if (!carriesRegistration(JSON.parse(readFileSync(settingsPath, 'utf-8')))) {
|
|
539
|
+
throw new Error(`${SETTINGS_RELATIVE} in ${projectRoot} did not take the PreToolUse registration — ` +
|
|
540
|
+
'the judge would never be spawned. Fix that file and re-run');
|
|
541
|
+
}
|
|
542
|
+
report.created.push(SETTINGS_RELATIVE);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Install the Claude Code session surface into `spec.projectRoot`, skipping whatever is
|
|
546
|
+
* already there and reporting both halves per artifact.
|
|
547
|
+
*
|
|
548
|
+
* Throws before any write when the umbrella cannot be resolved from that root, when the
|
|
549
|
+
* settings file cannot be parsed, or when the scaffold refuses — all three leave zero files.
|
|
550
|
+
* Translating a throw into exit 2 with the install command is the bin's job.
|
|
551
|
+
*/
|
|
552
|
+
export function initClaudeCode(spec) {
|
|
553
|
+
const resolvePolydeukes = spec.resolvePolydeukes ?? resolveFromProjectRoot;
|
|
554
|
+
let scaffoldBin;
|
|
555
|
+
try {
|
|
556
|
+
scaffoldBin = resolvePolydeukes(spec.projectRoot);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
// The message names the package because the user's next action is installing it — the
|
|
560
|
+
// seam's own message cannot be relied on to say so. The original is carried through
|
|
561
|
+
// rather than discarded: an experimental resolver can fail for reasons that are
|
|
562
|
+
// neither absence nor a stale version.
|
|
563
|
+
throw new Error(`cannot use '${UMBRELLA_PACKAGE}' from ${spec.projectRoot} — install or update it there ` +
|
|
564
|
+
`first (e.g. 'npm install --save-dev ${UMBRELLA_PACKAGE}'), then run this command ` +
|
|
565
|
+
`again: ${error instanceof Error ? error.message : String(error)}`);
|
|
566
|
+
}
|
|
567
|
+
// Every read that can fail is settled before the first write, and before the scaffold:
|
|
568
|
+
// a parse failure after it would leave a tree carrying the config alone.
|
|
569
|
+
const settings = readSettings(spec.projectRoot);
|
|
570
|
+
const spawnScaffold = spec.spawnScaffold ??
|
|
571
|
+
((scaffold) => spawnSync(scaffold.command, scaffold.args, { cwd: scaffold.cwd, stdio: 'inherit' }));
|
|
572
|
+
const scaffold = spawnScaffold({
|
|
573
|
+
command: process.execPath,
|
|
574
|
+
args: [scaffoldBin, 'init'],
|
|
575
|
+
cwd: spec.projectRoot,
|
|
576
|
+
});
|
|
577
|
+
if (scaffold.status !== 0) {
|
|
578
|
+
// Any non-zero status is a scaffold that did not run: registering a delegator into a
|
|
579
|
+
// tree with no config wires a judge whose every call fails closed.
|
|
580
|
+
throw new Error(`'pdks init' failed in ${spec.projectRoot} (exit ${String(scaffold.status)}) — ` +
|
|
581
|
+
'fix what it reported and re-run');
|
|
582
|
+
}
|
|
583
|
+
const report = { created: [], skipped: [] };
|
|
584
|
+
writeIfAbsent(spec.projectRoot, HOOK_RELATIVE, GENERATED_HOOK, report);
|
|
585
|
+
mergeSettings(spec.projectRoot, settings, report);
|
|
586
|
+
// Written last, after the registration the hook needs to ever be spawned. Every write
|
|
587
|
+
// between the hook file and that registration widens the window where a throw leaves a
|
|
588
|
+
// delegator nothing invokes — a tree that looks installed and is judged by nothing. These
|
|
589
|
+
// two artifacts are the ones whose absence costs only discoverability, so they go where
|
|
590
|
+
// a failure costs least.
|
|
591
|
+
writeIfAbsent(spec.projectRoot, DISCOVERY_RELATIVE, GENERATED_DISCOVERY, report);
|
|
592
|
+
writeIfAbsent(spec.projectRoot, SKILL_RELATIVE, GENERATED_SKILL, report);
|
|
593
|
+
return report;
|
|
594
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating the `polydeukes` install a session surface runs through.
|
|
3
|
+
*
|
|
4
|
+
* Both users of this module ask the same question from the same anchor — the project root,
|
|
5
|
+
* never this module's own location. Anchoring at the installer or the hook would answer for
|
|
6
|
+
* the graph THIS package was installed into, which is a different tree from the one being
|
|
7
|
+
* judged or wired.
|
|
8
|
+
*/
|
|
9
|
+
/** The umbrella package both the spawn and the preflight look for. */
|
|
10
|
+
export declare const UMBRELLA_PACKAGE = "polydeukes";
|
|
11
|
+
/**
|
|
12
|
+
* The absolute path of the umbrella's `pdks` bin as reachable from `projectRoot`, or
|
|
13
|
+
* `undefined` when there is nothing to spawn.
|
|
14
|
+
*
|
|
15
|
+
* ESM resolution specifically, because that is what a generated delegator's `await
|
|
16
|
+
* import(...)` runs; the CJS alternatives were measured disagreeing with it in both
|
|
17
|
+
* directions. `findPackageJSON` is experimental in Node 24, so its behaviour can still
|
|
18
|
+
* change.
|
|
19
|
+
*
|
|
20
|
+
* A manifest found but carrying no `bin.pdks` answers `undefined` too: spawning `node
|
|
21
|
+
* undefined` crashes with no verdict and no row, which reads to the caller as the same
|
|
22
|
+
* absence reached without saying so.
|
|
23
|
+
*/
|
|
24
|
+
export declare function findUmbrellaBin(projectRoot: string): string | undefined;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating the `polydeukes` install a session surface runs through.
|
|
3
|
+
*
|
|
4
|
+
* Both users of this module ask the same question from the same anchor — the project root,
|
|
5
|
+
* never this module's own location. Anchoring at the installer or the hook would answer for
|
|
6
|
+
* the graph THIS package was installed into, which is a different tree from the one being
|
|
7
|
+
* judged or wired.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { findPackageJSON } from 'node:module';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { isPlainObject } from '@polydeukes/core';
|
|
13
|
+
/** The umbrella package both the spawn and the preflight look for. */
|
|
14
|
+
export const UMBRELLA_PACKAGE = 'polydeukes';
|
|
15
|
+
/**
|
|
16
|
+
* The absolute path of the umbrella's `pdks` bin as reachable from `projectRoot`, or
|
|
17
|
+
* `undefined` when there is nothing to spawn.
|
|
18
|
+
*
|
|
19
|
+
* ESM resolution specifically, because that is what a generated delegator's `await
|
|
20
|
+
* import(...)` runs; the CJS alternatives were measured disagreeing with it in both
|
|
21
|
+
* directions. `findPackageJSON` is experimental in Node 24, so its behaviour can still
|
|
22
|
+
* change.
|
|
23
|
+
*
|
|
24
|
+
* A manifest found but carrying no `bin.pdks` answers `undefined` too: spawning `node
|
|
25
|
+
* undefined` crashes with no verdict and no row, which reads to the caller as the same
|
|
26
|
+
* absence reached without saying so.
|
|
27
|
+
*/
|
|
28
|
+
export function findUmbrellaBin(projectRoot) {
|
|
29
|
+
let manifestPath;
|
|
30
|
+
try {
|
|
31
|
+
// Absence throws here rather than returning undefined (Node 24.18); the branch below
|
|
32
|
+
// guards the documented `string | undefined` return.
|
|
33
|
+
manifestPath = findPackageJSON(UMBRELLA_PACKAGE, join(projectRoot, 'package.json'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (manifestPath === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
let manifest;
|
|
41
|
+
try {
|
|
42
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const bin = isPlainObject(manifest) ? manifest.bin : undefined;
|
|
48
|
+
const pdks = isPlainObject(bin) ? bin.pdks : undefined;
|
|
49
|
+
if (typeof pdks !== 'string')
|
|
50
|
+
return undefined;
|
|
51
|
+
return join(manifestPath, '..', pdks);
|
|
52
|
+
}
|