polydeukes 0.5.0 → 0.6.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.
@@ -11,10 +11,11 @@
11
11
  * blocked record. An empty domain is an explicit pass with no records.
12
12
  */
13
13
  import { resolve } from 'node:path';
14
- import { collectRangeChanges, collectStagedChanges, collectWorktreeChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
14
+ import { collectRangeChanges, collectStagedChanges, collectWorktreeChanges, covenantInputFromStagedChanges, observationSourceReader, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
15
15
  import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
16
16
  import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
17
17
  import { loadConfig } from './load-config.js';
18
+ import { unobservedPreStateReader } from './pre-state-reader.js';
18
19
  /**
19
20
  * The TTY witness predicate, or undefined when no valve can exist (no witness configured
20
21
  * or no TTY seam). It fires on the first registration that broke, names it from the
@@ -61,7 +62,9 @@ function recordFailClosed(telemetryPath) {
61
62
  */
62
63
  export function assembleCommitRegistrations(spec) {
63
64
  const { config, rootDir, covenant, witness } = spec;
64
- const { protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
65
+ const { protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings({
66
+ namespace: config.adapters?.git,
67
+ });
65
68
  // Union of the common list and the git-additive one, common first so first-occurrence
66
69
  // dedupe is deterministic. The session hook reads the common list alone.
67
70
  const protectedPaths = normalizeProtectedPaths({
@@ -74,14 +77,13 @@ export function assembleCommitRegistrations(spec) {
74
77
  mutatingToolNames: [STAGED_WRITE, STAGED_DELETE],
75
78
  witness,
76
79
  }),
77
- // No shell axis here, so command-family entries are left out. Context-family entries
78
- // stay in: with no transcript the compiler gives them skip registrations, which record
79
- // `skipped` on a match.
80
80
  ...covenant.compileDisciplineRegistrations({
81
- disciplines: disciplines.filter((entry) => entry.forbidCommand === undefined),
81
+ disciplines,
82
82
  rootDir,
83
83
  shellTools: [],
84
84
  commandArgs: [],
85
+ readPreState: unobservedPreStateReader,
86
+ observesChangeSet: true,
85
87
  witness,
86
88
  }),
87
89
  ];
@@ -104,7 +106,7 @@ function settleConfig(spec) {
104
106
  let telemetryPath;
105
107
  try {
106
108
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
107
- const { config } = loadConfig(spec.repoRoot);
109
+ const { config } = loadConfig({ rootDir: spec.repoRoot });
108
110
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
109
111
  return { settled: true, telemetryPath, config };
110
112
  }
@@ -118,12 +120,32 @@ function settleConfig(spec) {
118
120
  */
119
121
  function collectDomain(repoRoot, domain) {
120
122
  if (domain.kind === 'worktree')
121
- return collectWorktreeChanges(repoRoot);
123
+ return collectWorktreeChanges({ repoRoot });
122
124
  if (domain.kind === 'range') {
123
125
  const separator = domain.ancestry === 'merge-base' ? '...' : '..';
124
- return collectRangeChanges(repoRoot, `${domain.base}${separator}${domain.head}`);
126
+ return collectRangeChanges({
127
+ repoRoot,
128
+ range: `${domain.base}${separator}${domain.head}`,
129
+ });
130
+ }
131
+ return collectStagedChanges({ repoRoot });
132
+ }
133
+ /**
134
+ * The observation's change set: the paths of the collected changes that carry file-change
135
+ * evidence, in collection order.
136
+ *
137
+ * The same definition the judge derives its own set from, so both surfaces name the same
138
+ * changes. A deletion carries evidence and stays; a binary blob, which the collector gives
139
+ * a call with no evidence, produces no world of its own — listing it would hand the
140
+ * change-set relations a path no world can ever answer for.
141
+ */
142
+ function changedPaths(changes) {
143
+ const paths = [];
144
+ for (const call of covenantInputFromStagedChanges({ changes }).toolCalls) {
145
+ if (call.fileChange !== undefined)
146
+ paths.push(call.fileChange.path);
125
147
  }
126
- return collectStagedChanges(repoRoot);
148
+ return paths;
127
149
  }
128
150
  /**
129
151
  * Assemble the registrations and dispatch every collected change. Any throw here (an
@@ -132,7 +154,7 @@ function collectDomain(repoRoot, domain) {
132
154
  async function judgeChanges(spec, domain, telemetryPath, config, changes) {
133
155
  try {
134
156
  // Inside the try so an invalid adapter namespace fails closed.
135
- const { enforce } = resolveGitAdapterSettings(config.adapters?.git);
157
+ const { enforce } = resolveGitAdapterSettings({ namespace: config.adapters?.git });
136
158
  // Real Node resolution of the covenant package, so the commit surface runs the same
137
159
  // judges the session hook does; tests inject a directory instead. Awaited before any
138
160
  // registration is composed, so a dist the barrel cannot load fails the run closed here
@@ -155,14 +177,24 @@ async function judgeChanges(spec, domain, telemetryPath, config, changes) {
155
177
  covenant,
156
178
  witness,
157
179
  });
180
+ // One plan and one supply for the run: the per-change loop shares them, so the tree is
181
+ // read once per named file rather than once per change. `changes` carries the whole
182
+ // observation because this surface dispatches one change at a time to keep telemetry at
183
+ // one row per file — a set no judge could derive from the input it is handed.
184
+ const { files } = covenant.supplySources({
185
+ plan: covenant.planSources({ registrations }),
186
+ read: observationSourceReader({ repoRoot: spec.repoRoot, observation: domain }),
187
+ });
188
+ const world = { files, changes: changedPaths(changes) };
158
189
  for (const change of changes) {
159
- const input = covenantInputFromStagedChanges([change]);
190
+ const input = covenantInputFromStagedChanges({ changes: [change] });
160
191
  const { exitCode, results } = await covenant.dispatchCovenants({
161
192
  stdinPayload: JSON.stringify(input),
162
193
  registrations,
163
194
  telemetryPath,
164
195
  dispatcherLabel: 'covenant-check',
165
196
  enforce,
197
+ world,
166
198
  });
167
199
  if (exitCode === 2)
168
200
  blocked = true;
@@ -12,8 +12,8 @@
12
12
  * mirror where real Node resolution would always land on the healthy build.
13
13
  */
14
14
  import type * as covenant from '@polydeukes/covenant';
15
- /** The covenant surface both roots assemble against. */
16
- export type CovenantModule = typeof covenant;
15
+ /** The covenant surface both roots assemble against — the members they call, and no more. */
16
+ export type CovenantModule = Pick<typeof covenant, 'dispatchCovenants' | 'compileDisciplineRegistrations' | 'selfModRegistration' | 'shellModRegistration' | 'transcriptModRegistration' | 'planSources' | 'supplySources'>;
17
17
  /** Where real Node resolution puts the covenant package's built barrel. */
18
18
  export declare function resolveCovenantDist(): string;
19
19
  /**
@@ -11,6 +11,14 @@
11
11
  * The `covenantDist` seam selects WHICH dist is imported, so a fixture can inject a gutted
12
12
  * mirror where real Node resolution would always land on the healthy build.
13
13
  */
14
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
15
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
16
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
17
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
18
+ });
19
+ }
20
+ return path;
21
+ };
14
22
  import { createRequire } from 'node:module';
15
23
  import { join } from 'node:path';
16
24
  import { pathToFileURL } from 'node:url';
@@ -26,7 +34,7 @@ export function resolveCovenantDist() {
26
34
  */
27
35
  export async function loadCovenantModule(distDir) {
28
36
  try {
29
- return (await import(pathToFileURL(join(distDir, 'index.js')).href));
37
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(join(distDir, 'index.js')).href)));
30
38
  }
31
39
  catch (error) {
32
40
  throw new Error(`the covenant judges could not be loaded from ${distDir} — run 'pnpm build' to rebuild them: ${error instanceof Error ? error.message : String(error)}`);
@@ -3,7 +3,7 @@
3
3
  **English** · [한국어](./configuration.ko.md)
4
4
 
5
5
  > Alpha. This guide covers the config surface as shipped today (schema v2, loader, and
6
- > the four built-in discipline predicates). Fields and predicates will grow; what is
6
+ > the declaration grammar). Fields and steps will grow; what is
7
7
  > written here is tested and enforced now.
8
8
 
9
9
  `polydeukes.config.yaml` is the one file where a project declares its disciplines — the
@@ -43,7 +43,8 @@ judge from your project's own installed package.
43
43
  From the project root:
44
44
 
45
45
  ```sh
46
- pnpm exec pdks init claude-code
46
+ pnpm exec pdks init claude-code # Claude Code
47
+ pnpm exec pdks init grok # Grok
47
48
  ```
48
49
 
49
50
  The command installs into the directory it is invoked from, and it proves the `polydeukes`
@@ -51,9 +52,17 @@ package resolves there **before writing anything** — if it does not (say, the
51
52
  was skipped), it prints the install command and exits 2 with zero files written, never a
52
53
  half-wired tree.
53
54
 
54
- Six artifacts, none ever overwritten. What exists is reported and kept — the hook, the
55
- config, and the discipline files are left alone, the settings file is merged, and
56
- `.gitignore` is only ever appended to — so re-running is always safe:
55
+ Nothing existing is overwritten. What exists is reported and kept — the hook, the config,
56
+ and the discipline files are left alone, the settings file is merged, and `.gitignore` is
57
+ only ever appended to — so re-running is always safe. One command-field exception: if
58
+ `.grok/hooks/covenant-pretooluse.json` still names the grok delegator and a Claude
59
+ delegator is on disk, the JSON `command` is rewritten to that Claude file so the host
60
+ does not spawn two judges. Grok collapses two registrations only when `command` AND
61
+ `matcher` are identical, so every grok entry naming that Claude file also takes the matcher
62
+ of the `.claude/settings.json` entry that registers the same command — on a fresh write and
63
+ on every re-run; `timeout` stays. A command you pointed elsewhere is left as it was.
64
+
65
+ `pdks init claude-code` writes six artifacts:
57
66
 
58
67
  | Artifact | What it is |
59
68
  |---|---|
@@ -64,6 +73,25 @@ config, and the discipline files are left alone, the settings file is merged, an
64
73
  | `.claude/skills/discipline-draft/SKILL.md` | The classification procedure. Describe a recurring problem to your AI partner and it lands as a config entry — judged at advise when a current family can express it, a `draft: true` entry otherwise — and the same file tells the agent to consult `advised` rows in the telemetry log at task boundaries. |
65
74
  | `.gitignore` | An appended ignore rule for `.polydeukes/`, with its comment line — telemetry is local observation data and never belongs in history. |
66
75
 
76
+ `pdks init grok` shares the scaffold (config and the ignore line) and writes Grok's own
77
+ registration. A Grok-only tree has four artifacts, and no `.claude/` directory:
78
+
79
+ | Artifact | What it is |
80
+ |---|---|
81
+ | `.grok/hooks/covenant-pretooluse.mjs` | The hook — the same delegator text, only when no Claude delegator is already on disk. |
82
+ | `.grok/hooks/covenant-pretooluse.json` | The PreToolUse matcher, `timeout` 60 (the host default is 5 seconds, and a timed-out hook fails open), and the command that names one delegator file. In a tree that also has `.claude/settings.json`, the matcher is copied from the settings entry with the same command — Grok reads that file too, and collapses the two registrations into one spawn only when `command` and `matcher` match exactly. That copy leans on Grok's tool-name aliases, so if you later remove `.claude/settings.json`, delete this JSON and run `pdks init grok` again to get the Grok-native matcher back. |
83
+ | `polydeukes.config.yaml` | The same starter policy as above. |
84
+ | `.gitignore` | The same appended ignore line. |
85
+
86
+ If `.claude/hooks/covenant-pretooluse.mjs` already exists, the JSON command points at that
87
+ file instead of planting a second one. A later `pdks init grok` or `pdks init claude-code`
88
+ retargets an installer-generated grok-mjs command the same way.
89
+
90
+ An already-open Grok session keeps the hook snapshot from start. Reload from the Hooks tab
91
+ (`r`) or start a new session. The witness valve does not open on Grok — the session log is
92
+ ACP `updates.jsonl`, not Claude's JSONL. A block is recovered from another terminal or the
93
+ commit-surface TTY.
94
+
67
95
  ## First edit — `languages`
68
96
 
69
97
  The generated config ships a placeholder language profile, because the installer cannot
@@ -148,12 +176,12 @@ Three things to know about this surface:
148
176
  - **The valve is a TTY prompt.** At the default `block` level, a commit that stages a
149
177
  protected change stops at a prompt only a human at a terminal can answer. Configure your
150
178
  hook runner so it does not swallow that prompt (lefthook needs `interactive: true`).
151
- - **Two discipline families judge here.** A staged diff carries file changes and nothing
152
- else, so protection lists and the delta and path families (`forbid`, `immutable`) judge
153
- in full. A command-family entry (`forbidCommand`) has no command line to read in a
154
- staged diff and is not assembled on this surface, and a context-family entry
155
- (`requirePrecedent`) is recorded as `skipped` — declare those two where an AI partner's
156
- session exists to be judged.
179
+ - **Declarations judge here.** A staged diff carries file changes and nothing else, so
180
+ protection lists and every `declare` entry over the change judge in full. A declaration
181
+ scoped on `command` has no command line to read in a staged diff and observes nothing on
182
+ this surface, and a declaration that reads the session (`precedent` and the other history
183
+ mechanisms) is recorded as `skipped` — declare those where an AI partner's session exists
184
+ to be judged.
157
185
  - **The commit surface has its own additive scope.** Paths that are fine to edit freely
158
186
  but whose promotion into history deserves a judged checkpoint go under the adapter
159
187
  namespace, judged on top of the shared list:
@@ -185,8 +213,9 @@ witness:
185
213
 
186
214
  Change the token and window as you like — the token is not a secret; the defence is
187
215
  provenance, not confidentiality. **Keep the block**: on the session surface the generated
188
- protection list covers `.claude/hooks`, so without a valve the first blocked call would
189
- freeze the project until a human edits the config from their own terminal.
216
+ protection list covers `.claude/hooks` and `.grok/hooks`, so without a valve the first
217
+ blocked call would freeze the project until a human edits the config from their own
218
+ terminal.
190
219
 
191
220
  ## Prove the gate is live
192
221
 
@@ -21,11 +21,12 @@ agent-neutrality a claim a test can check rather than a slogan.
21
21
  | Virtual post-state | Computes what a file *would* contain after an edit applies, without touching disk |
22
22
  | File-change evidence | Pairs the disk pre-state with the virtual post-state into union evidence |
23
23
  | Transcript provider | Turns a session JSONL file into a `CanonicalTranscript` |
24
- | Precedent evaluator | This adapter's own evidence vocabulary for the context family |
25
24
  | Telemetry wiring | Drives the full funnel so exactly one row lands per call |
26
25
 
27
26
  This package never imports the covenant package. The dispatch seam is *injected* by the
28
- umbrella, which keeps dependencies one-way, through the core alone.
27
+ umbrella, which keeps dependencies one-way, through the core alone. It names
28
+ `@polydeukes/core` as a `peerDependency`: the vocabulary is shared with the judge, not
29
+ installed a second time here.
29
30
 
30
31
  ## Payload translation and the three axes
31
32
 
@@ -39,7 +40,9 @@ umbrella, which keeps dependencies one-way, through the core alone.
39
40
 
40
41
  Translation is fail-closed at every step. A `Task` call carrying a subagent type maps to a
41
42
  spawn; a payload that cannot be classified is a translation *failure* that logs one
42
- `blocked` record and exits `2`, rather than degrading into a guess.
43
+ `blocked` record and exits `2`, rather than degrading into a guess. The envelope's top-level
44
+ `agent_type` becomes the IR's `actor` — `{ agentType }` inside a subagent, `{}` otherwise;
45
+ `tool_input` is never read for it, since that is the agent's own text.
43
46
 
44
47
  **Evidence is computed, never read back.** The virtual post-state applies `Edit`, `Write`,
45
48
  and `MultiEdit` in memory — sequential multi-edit application included — so a content-aware
@@ -62,7 +65,6 @@ the compiler the evidence is unjudgeable, so the entry skips instead of judging
62
65
 
63
66
  - **The generated hook**, which loads this adapter through the umbrella's `claude-code`
64
67
  subpath. Upgrading the package upgrades what runs; the hook file itself never changes.
65
- - **`requirePrecedent` entries** using the `subagent` or `tool` evidence keys.
66
68
 
67
69
  No import, and no configuration namespace of its own.
68
70
 
@@ -23,12 +23,13 @@ AI or human.
23
23
 
24
24
  This is a pure library. It knows the staged-diff shape and nothing about installation, hook
25
25
  runners, or valves — wiring it into a pre-commit hook is a deployment act that lives in the
26
- umbrella.
26
+ umbrella. It names `@polydeukes/core` as a `peerDependency`: the vocabulary is shared with
27
+ the judge, not installed a second time here.
27
28
 
28
29
  ## Collection and the `adapters.git` namespace
29
30
 
30
31
  **Three collectors, one shape.** `collectStagedChanges`, `collectWorktreeChanges`, and
31
- `collectRangeChanges(repoRoot, '<base>..<head>' | '<base>...<head>')` each return the same
32
+ `collectRangeChanges({ repoRoot, range: '<base>..<head>' | '<base>...<head>' })` each return the same
32
33
  `StagedChange[]`, so the translator and everything after it is one path.
33
34
 
34
35
  | Collector | `pre` | `post` | Also |
@@ -52,7 +53,8 @@ and `staged-delete`. A deletion always carries its evidence. A write carries it
52
53
  staged blob was binary — there is no text to compare, so the call arrives with no
53
54
  `fileChange` at all and is judged on its path alone, the same as any unproven call.
54
55
  **The session collections are honestly empty** — the commit surface has no session, and a
55
- key is never fabricated to look like one.
56
+ key is never fabricated to look like one. There is no `actor` either: the hook cannot tell a
57
+ human's `git commit` from an agent's, so it proves none.
56
58
 
57
59
  **The namespace is this adapter's own vocabulary.** The core validates only the container
58
60
  shape — one settings object per adapter — and passes the contents through verbatim, so the
@@ -87,9 +89,9 @@ No import.
87
89
 
88
90
  ## Declared limits
89
91
 
90
- - **The context family cannot be judged here.** `requirePrecedent` needs session history
91
- and a commit has none, so a matching entry records `skipped`. A permanent condition of
92
- this surface, not a fault in the entry.
92
+ - **A declaration that reads the session cannot be judged here.** A `precedent` needs
93
+ session history and a commit has none, so a matching entry records `skipped`. A permanent
94
+ condition of this surface, not a fault in the entry.
93
95
  - **A commit never shows a gitignored file.** Anything outside version control — a built
94
96
  `dist`, a generated hook script — is invisible to this surface by nature. That is why the
95
97
  session surface carries those paths on the common list instead.