polydeukes 0.7.1 → 0.9.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.
Files changed (45) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/dist/covenant/discipline.d.ts +6 -0
  4. package/dist/covenant/discipline.js +1 -1
  5. package/dist/covenant/dispatch.d.ts +10 -7
  6. package/dist/covenant/dispatch.js +21 -8
  7. package/dist/covenant/shell-mod.d.ts +15 -4
  8. package/dist/covenant/shell-mod.js +63 -8
  9. package/dist/covenant/transcript-mod.js +8 -5
  10. package/dist/covenant-check.d.ts +2 -2
  11. package/dist/covenant-check.js +113 -9
  12. package/dist/docs/README.ko.md +3 -2
  13. package/dist/docs/README.md +3 -2
  14. package/dist/docs/catalog.json +32 -0
  15. package/dist/docs/how-to/connect-surfaces.ko.md +45 -5
  16. package/dist/docs/how-to/connect-surfaces.md +49 -7
  17. package/dist/docs/how-to/write-disciplines.ko.md +3 -1
  18. package/dist/docs/how-to/write-disciplines.md +3 -1
  19. package/dist/docs/index.json +338 -190
  20. package/dist/docs/reference/cli/explain.ko.md +8 -8
  21. package/dist/docs/reference/cli/explain.md +10 -8
  22. package/dist/docs/reference/cli/init.ko.md +46 -4
  23. package/dist/docs/reference/cli/init.md +48 -5
  24. package/dist/docs/reference/configuration/index.ko.md +11 -5
  25. package/dist/docs/reference/configuration/index.md +10 -5
  26. package/dist/docs/reference/packages/adapter-claude-code.ko.md +5 -4
  27. package/dist/docs/reference/packages/adapter-claude-code.md +6 -4
  28. package/dist/docs/reference/packages/adapter-codex.ko.md +116 -0
  29. package/dist/docs/reference/packages/adapter-codex.md +118 -0
  30. package/dist/docs/reference/packages/adapter-grok.ko.md +6 -2
  31. package/dist/docs/reference/packages/adapter-grok.md +6 -2
  32. package/dist/docs/reference/packages/core.ko.md +1 -1
  33. package/dist/docs/reference/packages/core.md +1 -1
  34. package/dist/docs/reference/packages/polydeukes.ko.md +4 -3
  35. package/dist/docs/reference/packages/polydeukes.md +5 -4
  36. package/dist/docs/reference/packages/sdk-ts.ko.md +6 -3
  37. package/dist/docs/reference/packages/sdk-ts.md +6 -3
  38. package/dist/docs/troubleshooting.ko.md +39 -5
  39. package/dist/docs/troubleshooting.md +41 -5
  40. package/dist/docs/tutorials/first-judgment.ko.md +5 -3
  41. package/dist/docs/tutorials/first-judgment.md +5 -3
  42. package/dist/load-config.d.ts +24 -0
  43. package/dist/load-config.js +20 -2
  44. package/dist/scaffold-project.js +3 -1
  45. package/package.json +4 -3
package/README.ko.md CHANGED
@@ -17,7 +17,9 @@ CLI 명령은 다음과 같습니다.
17
17
 
18
18
  - `pdks covenant check`
19
19
  - `pdks init`
20
+ - `pdks-claude-code init` (`@polydeukes/adapter-claude-code`가 제공)
20
21
  - `pdks-grok init` (`@polydeukes/adapter-grok`가 제공)
22
+ - `pdks-codex init` (`@polydeukes/adapter-codex`가 제공)
21
23
  - `pdks explain`
22
24
  - `pdks docs [topic]`
23
25
 
package/README.md CHANGED
@@ -18,7 +18,9 @@ CLI commands:
18
18
 
19
19
  - `pdks covenant check`
20
20
  - `pdks init`
21
+ - `pdks-claude-code init` (ships with `@polydeukes/adapter-claude-code`)
21
22
  - `pdks-grok init` (ships with `@polydeukes/adapter-grok`)
23
+ - `pdks-codex init` (ships with `@polydeukes/adapter-codex`)
22
24
  - `pdks explain`
23
25
  - `pdks docs [topic]`
24
26
 
@@ -41,6 +41,12 @@ export type CompileDisciplinesSpec = {
41
41
  witness?: CovenantRegistration['witness'];
42
42
  transcript?: CanonicalTranscript;
43
43
  };
44
+ /**
45
+ * Relativize a file-change path against the root for glob matching. A relative path passes
46
+ * through; an absolute path outside `rootDir` yields null (never matches — discipline scope
47
+ * is declared repo-relative).
48
+ */
49
+ export declare function relativizeForScope(filePath: string, rootDir: string): string | null;
44
50
  /** One change as one world, under the repo-relative path the declaration's scope reads. */
45
51
  export type SuppliedWorld = {
46
52
  readonly path: string;
@@ -18,7 +18,7 @@ import { deriveShellChanges } from './shell-evidence.js';
18
18
  * through; an absolute path outside `rootDir` yields null (never matches — discipline scope
19
19
  * is declared repo-relative).
20
20
  */
21
- function relativizeForScope(filePath, rootDir) {
21
+ export function relativizeForScope(filePath, rootDir) {
22
22
  if (!isAbsolute(filePath)) {
23
23
  // A relative spelling normalizes before matching — `./x` and `a/../x` name x, and a
24
24
  // spelling that resolves out of the root matches nothing. Matching verbatim instead
@@ -83,13 +83,16 @@ export type CovenantRegistration = {
83
83
  * Match registrations against a {@link CovenantInput} by path mention (pure).
84
84
  *
85
85
  * A registration matches when any of its `protectedPaths` is an ancestor/descendant/equal of
86
- * a path candidate extracted from any string value reachable at any depth inside
87
- * `input.toolCalls[].args`; candidates are quote-aware tokenizer words, so a quote-split
88
- * write still routes. An unread span with a non-empty `protectedPaths` routes fail-closed
89
- * (the registration matches on its first protected path) rather than silently miss.
90
- * `subagentSpawns` and `userMessages` never participate. `mentionedPath` is the first
91
- * protected path (in array order) that mentions. Result preserves registration order, at most
92
- * one entry per registration.
86
+ * a path candidate. Candidates come from two places: every string value reachable at any
87
+ * depth inside `input.toolCalls[].args`, read as quote-aware tokenizer words so a
88
+ * quote-split write still routes, and each call's proven `fileChange` path, taken whole
89
+ * because an adapter already resolved it. A call carrying only the latter is the ordinary
90
+ * shape where a host names its target in a form the adapter must parse — the patch text of
91
+ * one edit rather than a path argument. An unread span with a non-empty `protectedPaths`
92
+ * routes fail-closed (the registration matches on its first protected path) rather than
93
+ * silently miss. `subagentSpawns` and `userMessages` never participate. `mentionedPath` is
94
+ * the first protected path (in array order) that mentions. Result preserves registration
95
+ * order, at most one entry per registration.
93
96
  *
94
97
  * A registration carrying a `matches` predicate routes on it exclusively, path mention
95
98
  * skipped for it: non-null return → included with that string as `mentionedPath`; null →
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { appendRecordFailOpen, EXIT_BREAK_BLOCKING, EXIT_UPHOLD, noopTranscript, parseInput, } from '@polydeukes/core';
11
11
  import { tokenizeCommandLine } from './bash-line.js';
12
- import { pathCandidates, pathMatchesProtected } from './mention.js';
12
+ import { pathCandidates, pathMatchesProtected, provenChangePath } from './mention.js';
13
13
  import { runCovenant } from './run-covenant.js';
14
14
  /**
15
15
  * Collect path candidates from every string value inside `value`. Each string is tokenized
@@ -55,13 +55,16 @@ function collectPathCandidates(value) {
55
55
  * Match registrations against a {@link CovenantInput} by path mention (pure).
56
56
  *
57
57
  * A registration matches when any of its `protectedPaths` is an ancestor/descendant/equal of
58
- * a path candidate extracted from any string value reachable at any depth inside
59
- * `input.toolCalls[].args`; candidates are quote-aware tokenizer words, so a quote-split
60
- * write still routes. An unread span with a non-empty `protectedPaths` routes fail-closed
61
- * (the registration matches on its first protected path) rather than silently miss.
62
- * `subagentSpawns` and `userMessages` never participate. `mentionedPath` is the first
63
- * protected path (in array order) that mentions. Result preserves registration order, at most
64
- * one entry per registration.
58
+ * a path candidate. Candidates come from two places: every string value reachable at any
59
+ * depth inside `input.toolCalls[].args`, read as quote-aware tokenizer words so a
60
+ * quote-split write still routes, and each call's proven `fileChange` path, taken whole
61
+ * because an adapter already resolved it. A call carrying only the latter is the ordinary
62
+ * shape where a host names its target in a form the adapter must parse — the patch text of
63
+ * one edit rather than a path argument. An unread span with a non-empty `protectedPaths`
64
+ * routes fail-closed (the registration matches on its first protected path) rather than
65
+ * silently miss. `subagentSpawns` and `userMessages` never participate. `mentionedPath` is
66
+ * the first protected path (in array order) that mentions. Result preserves registration
67
+ * order, at most one entry per registration.
65
68
  *
66
69
  * A registration carrying a `matches` predicate routes on it exclusively, path mention
67
70
  * skipped for it: non-null return → included with that string as `mentionedPath`; null →
@@ -70,6 +73,16 @@ function collectPathCandidates(value) {
70
73
  */
71
74
  export function matchRegistrations(input, registrations) {
72
75
  const { candidates, failed } = collectPathCandidates(input.toolCalls.map((call) => call.args));
76
+ // A proven change path joins the candidates directly, untokenized: it is a path the
77
+ // adapter already resolved, not a line to be read for one, and tokenizing it would split
78
+ // a path containing a space into words that name nothing. Without this a call whose only
79
+ // evidence is its `fileChange` routes nowhere — the judge bodies read that evidence, so
80
+ // the input would be refused before the layer that can settle it.
81
+ for (const call of input.toolCalls) {
82
+ const proven = provenChangePath(call);
83
+ if (proven !== null)
84
+ candidates.push(proven);
85
+ }
73
86
  const matches = [];
74
87
  for (const registration of registrations) {
75
88
  if (registration.matches !== undefined) {
@@ -4,7 +4,7 @@
4
4
  * Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
5
5
  * values, never source literals) per simple command: the fixed detection rules catch writes
6
6
  * to a protected path, undecidable structures (opaque mentions, opaque write targets) fail
7
- * closed, the read-only allowlist absolves proven reads, and every other protected-path
7
+ * closed, read-only proof absolves safe reads, and every other protected-path
8
8
  * mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
9
9
  * tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
10
10
  * run-all co-existence depends on that boundary.
@@ -18,7 +18,7 @@ import type { CovenantRegistration, MetaCovenantRegistration } from './dispatch.
18
18
  * `protectedPaths` are literal path strings; `shellToolNames` are the tool names whose
19
19
  * calls carry shell lines; `commandArgNames` are the `args` keys those lines live under;
20
20
  * `readOnlyCommands` are allowlist entries — space-separated word sequences (`'cat'`,
21
- * `'git diff'`). Empty-string entries in every list are ignored (an unguarded `''` would
21
+ * `'git status'`). Empty-string entries in every list are ignored (an unguarded `''` would
22
22
  * match every path / tool / arg / command).
23
23
  */
24
24
  export type ShellModificationSpec = {
@@ -38,6 +38,16 @@ export type ShellModificationSpec = {
38
38
  * redirect-free truncating write. `git status`/`git grep` reject `--output`, so they stay.
39
39
  */
40
40
  export declare const DEFAULT_READ_ONLY_COMMANDS: string[];
41
+ /**
42
+ * True when a configured allowlist has the same effective entries as the shipped default.
43
+ * Conditional readers belong to that default contract and stay disabled for replacements.
44
+ */
45
+ export declare function usesDefaultReadOnlyCommands(commands: string[]): boolean;
46
+ /**
47
+ * Prove the finite argument-sensitive readers that cannot be represented by a leading-word
48
+ * allowlist. Every word must be transparent because these readers inspect their later words.
49
+ */
50
+ export declare function matchesConditionalReadOnlyCommand(command: SimpleCommand): boolean;
41
51
  /**
42
52
  * True when the command's leading words match the allowlist entry's word sequence. Exported
43
53
  * so the transcript judge's allowlist clause absolves reads by this exact comparison instead
@@ -59,8 +69,9 @@ export declare function judgeShellModification(input: CovenantInput, spec: Shell
59
69
  /**
60
70
  * `ShellModRegistrationSpec` — the assembly values baked into the registration. The call
61
71
  * set is not among them: the dispatcher supplies it to the judge at call time.
62
- * `readOnlyCommands` REPLACES {@link DEFAULT_READ_ONLY_COMMANDS} when given — no merge,
63
- * since an assembly wanting to extend the default spreads the constant.
72
+ * `readOnlyCommands` REPLACES {@link DEFAULT_READ_ONLY_COMMANDS} when given — no merge.
73
+ * Any replacement, including a superset made by spreading the default, disables the finite
74
+ * argument-sensitive readers because their proof belongs to the exact shipped default.
64
75
  */
65
76
  export type ShellModRegistrationSpec = {
66
77
  protectedPaths: string[];
@@ -4,7 +4,7 @@
4
4
  * Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
5
5
  * values, never source literals) per simple command: the fixed detection rules catch writes
6
6
  * to a protected path, undecidable structures (opaque mentions, opaque write targets) fail
7
- * closed, the read-only allowlist absolves proven reads, and every other protected-path
7
+ * closed, read-only proof absolves safe reads, and every other protected-path
8
8
  * mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
9
9
  * tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
10
10
  * run-all co-existence depends on that boundary.
@@ -39,6 +39,58 @@ export const DEFAULT_READ_ONLY_COMMANDS = [
39
39
  'git status',
40
40
  'git grep',
41
41
  ];
42
+ const FIND_WRITE_OR_EXECUTE_ACTIONS = new Set([
43
+ '-delete',
44
+ '-exec',
45
+ '-execdir',
46
+ '-ok',
47
+ '-okdir',
48
+ '-fprint',
49
+ '-fprint0',
50
+ '-fprintf',
51
+ '-fls',
52
+ ]);
53
+ function normalizedReadOnlyCommands(commands) {
54
+ return [
55
+ ...new Set(commands
56
+ .map((entry) => entry
57
+ .split(/\s+/)
58
+ .filter((word) => word !== '')
59
+ .join(' '))
60
+ .filter((entry) => entry !== '')),
61
+ ].sort();
62
+ }
63
+ /**
64
+ * True when a configured allowlist has the same effective entries as the shipped default.
65
+ * Conditional readers belong to that default contract and stay disabled for replacements.
66
+ */
67
+ export function usesDefaultReadOnlyCommands(commands) {
68
+ const configured = normalizedReadOnlyCommands(commands);
69
+ const shipped = normalizedReadOnlyCommands(DEFAULT_READ_ONLY_COMMANDS);
70
+ return (configured.length === shipped.length && configured.every((entry, i) => entry === shipped[i]));
71
+ }
72
+ /**
73
+ * Prove the finite argument-sensitive readers that cannot be represented by a leading-word
74
+ * allowlist. Every word must be transparent because these readers inspect their later words.
75
+ */
76
+ export function matchesConditionalReadOnlyCommand(command) {
77
+ if (command.words.length === 0 || command.words.some((word) => word.opaque))
78
+ return false;
79
+ const name = commandBasename(command.words[0]);
80
+ if (name === 'git')
81
+ return command.words[1]?.text === 'ls-files';
82
+ if (name === 'find') {
83
+ return !command.words.some((word) => FIND_WRITE_OR_EXECUTE_ACTIONS.has(word.text));
84
+ }
85
+ if (name !== 'sed')
86
+ return false;
87
+ const script = command.words[2]?.text;
88
+ return (command.words[1]?.text === '-n' &&
89
+ script !== undefined &&
90
+ /^\d+(?:,\d+)?p$/.test(script) &&
91
+ command.words.length > 3 &&
92
+ command.words.slice(3).every((word) => !word.text.startsWith('-')));
93
+ }
42
94
  // The rule set is fixed, not injectable: dropping a rule from an assembly would be a
43
95
  // detection hole, and no consumer needs a subset.
44
96
  const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
@@ -65,9 +117,9 @@ export function matchesReadOnlyEntry(command, entry) {
65
117
  * Judge one simple command. Returns the break reason, or null when the command contributes
66
118
  * to uphold. The clause order below is normative: each clause exists to be reached before
67
119
  * the next one can absolve. `lineFullyRead` is false when the line carried a span the
68
- * tokenizer could not read, which withholds the allowlist clause.
120
+ * tokenizer could not read, which withholds read-only proof.
69
121
  */
70
- function judgeCommand(command, protectedPaths, readOnlyEntries, lineFullyRead) {
122
+ function judgeCommand(command, protectedPaths, readOnlyEntries, conditionalReadersEnabled, lineFullyRead) {
71
123
  // (a) Precise rules: a detected mutation whose target carries a protected path breaks.
72
124
  for (const rule of MUTATION_RULES) {
73
125
  for (const target of rule.detect(command)) {
@@ -100,16 +152,18 @@ function judgeCommand(command, protectedPaths, readOnlyEntries, lineFullyRead) {
100
152
  if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
101
153
  return `opaque redirect target alongside protected path ${mentioned}`;
102
154
  }
103
- // (e) Read-only allowlist: a proven read absolves the mention but a nested shell
104
- // (`eval`/`sh -c …`) re-parses its string args, so it can never be proven read-only even
105
- // if it was injected into the allowlist. Its mention falls through to the backstop. A line
155
+ // (e) Read-only proof: the allowlist or a finite argument-sensitive reader absolves the
156
+ // mention. A nested shell (`eval`/`sh -c …`) re-parses its string args, so it can never be
157
+ // proven read-only even if it was injected into the allowlist. Its mention falls through
158
+ // to the backstop. A line
106
159
  // carrying an unread span is refused the same way: what the scanner never read could be
107
160
  // anything, so no head vouches for it.
108
161
  const first = command.words[0];
109
162
  const firstBasename = first !== undefined ? commandBasename(first) : '';
110
163
  if (lineFullyRead &&
111
164
  !isNestedShellCommand(firstBasename) &&
112
- readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
165
+ (readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry)) ||
166
+ (conditionalReadersEnabled && matchesConditionalReadOnlyCommand(command)))) {
113
167
  return null;
114
168
  }
115
169
  // (f) Backstop — mention + unproven = block.
@@ -133,6 +187,7 @@ export function judgeShellModification(input, spec) {
133
187
  const readOnlyEntries = spec.readOnlyCommands
134
188
  .map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
135
189
  .filter((entry) => entry.length > 0);
190
+ const conditionalReadersEnabled = usesDefaultReadOnlyCommands(spec.readOnlyCommands);
136
191
  for (const call of input.toolCalls) {
137
192
  if (!shellToolNames.includes(call.name)) {
138
193
  continue;
@@ -167,7 +222,7 @@ export function judgeShellModification(input, spec) {
167
222
  }
168
223
  }
169
224
  for (const command of commands) {
170
- const reason = judgeCommand(command, protectedPaths, readOnlyEntries, unread.length === 0);
225
+ const reason = judgeCommand(command, protectedPaths, readOnlyEntries, conditionalReadersEnabled, unread.length === 0);
171
226
  if (reason !== null)
172
227
  return { upheld: false, reason };
173
228
  }
@@ -13,7 +13,7 @@ import { isNestedShellCommand, tokenizeCommandLine } from './bash-line.js';
13
13
  import { pathCandidates, pathSegments, provenChangePath, resolveDotSegments, someStringValue, untokenizableLineCandidates, } from './mention.js';
14
14
  import { commandBasename, redirectWriteRule, sedInPlaceRule, teeRule } from './mutation-rules.js';
15
15
  import { outcomeFromVerdict, UNJUDGEABLE_OUTCOME } from './run-covenant.js';
16
- import { DEFAULT_READ_ONLY_COMMANDS, matchesReadOnlyEntry } from './shell-mod.js';
16
+ import { DEFAULT_READ_ONLY_COMMANDS, matchesConditionalReadOnlyCommand, matchesReadOnlyEntry, usesDefaultReadOnlyCommands, } from './shell-mod.js';
17
17
  // The rule set is fixed, assembled exactly as shell-mod assembles it: dropping one would be
18
18
  // a detection hole, and the two judges must not diverge on what counts as a write.
19
19
  const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
@@ -55,6 +55,7 @@ function resolveTranscript(spec) {
55
55
  readOnlyEntries: spec.readOnlyCommands
56
56
  .map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
57
57
  .filter((entry) => entry.length > 0),
58
+ conditionalReadersEnabled: usesDefaultReadOnlyCommands(spec.readOnlyCommands),
58
59
  };
59
60
  }
60
61
  /**
@@ -96,7 +97,7 @@ function argsNameTranscript(value, transcript) {
96
97
  * it has to break before the allowlist gets a chance to absolve it. The letters skip `(c)`
97
98
  * because this ladder has no opaque-mention clause, matching the shell ladder's numbering
98
99
  * rather than closing the gap. `lineFullyRead` is false when the line carried a span the
99
- * tokenizer could not read, which withholds the allowlist clause.
100
+ * tokenizer could not read, which withholds read-only proof.
100
101
  */
101
102
  function judgeCommand(command, transcript, lineFullyRead) {
102
103
  // (a) Precise rules: a detected mutation whose target is the transcript breaks.
@@ -118,15 +119,17 @@ function judgeCommand(command, transcript, lineFullyRead) {
118
119
  if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
119
120
  return `opaque redirect target alongside the session transcript ${transcript.path}`;
120
121
  }
121
- // (e) Read-only allowlist: a proven read absolves the mention, in every spelling — but a
122
- // nested shell (`eval`/`sh -c …`) re-parses its string args, so it is never provably a read.
122
+ // (e) Read-only proof: the allowlist or a finite argument-sensitive reader absolves the
123
+ // mention in every spelling. A nested shell (`eval`/`sh -c …`) re-parses its string args,
124
+ // so it is never provably a read.
123
125
  // A line carrying an unread span is refused the same way: reading the session is free, but
124
126
  // only on a line we finished reading.
125
127
  const first = command.words[0];
126
128
  const firstBasename = first !== undefined ? commandBasename(first) : '';
127
129
  if (lineFullyRead &&
128
130
  !isNestedShellCommand(firstBasename) &&
129
- transcript.readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
131
+ (transcript.readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry)) ||
132
+ (transcript.conditionalReadersEnabled && matchesConditionalReadOnlyCommand(command)))) {
130
133
  return null;
131
134
  }
132
135
  // (f) Backstop — mention + unproven = block.
@@ -13,7 +13,7 @@
13
13
  import { type CanonicalTranscript, type CovenantInput } from '@polydeukes/core';
14
14
  import type { CovenantRegistration } from './covenant/dispatch.ts';
15
15
  import { type CovenantModule } from './covenant/module.ts';
16
- import { loadConfig } from './load-config.ts';
16
+ import { type LoadedConfig } from './load-config.ts';
17
17
  /** {@link runCovenantCheck} result — the exit code the check process leaves with. */
18
18
  export type CovenantCheckOutcome = {
19
19
  exitCode: 0 | 2;
@@ -57,7 +57,7 @@ export type CovenantCheckSpec = {
57
57
  };
58
58
  /** {@link assembleCheckRegistrations} input — what this runner's assembly needs. */
59
59
  export type CheckAssemblySpec = {
60
- config: ReturnType<typeof loadConfig>['config'];
60
+ config: LoadedConfig['config'];
61
61
  rootDir: string;
62
62
  /**
63
63
  * The judge module the registrations are built from, so what judges a change and what
@@ -10,14 +10,16 @@
10
10
  * own `world` exits 2 with one blocked record. An input with no toolCalls is an explicit pass
11
11
  * with no records.
12
12
  */
13
+ import { readFileSync } from 'node:fs';
13
14
  import { homedir } from 'node:os';
14
- import { resolve } from 'node:path';
15
+ import { join, resolve } from 'node:path';
15
16
  import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, isPlainObject, normalizeProtectedPaths, transcriptFromSession, } from '@polydeukes/core';
16
17
  import { compareBaseline, updateBaseline } from './baseline.js';
18
+ import { relativizeForScope } from './covenant/discipline.js';
17
19
  import { covenantModule } from './covenant/module.js';
18
20
  import { ttlWitness } from './covenant/ttl-witness.js';
19
21
  import { STAGED_DELETE, STAGED_WRITE } from './diff-ir.js';
20
- import { loadConfig } from './load-config.js';
22
+ import { discoverConfigPath, parseConfigSource } from './load-config.js';
21
23
  import { sessionPreStateReader, unobservedPreStateReader } from './pre-state-reader.js';
22
24
  import { worktreeReader } from './worktree-reader.js';
23
25
  /**
@@ -115,21 +117,38 @@ export function assembleCheckRegistrations(spec) {
115
117
  * `surface: 'changeSet'` and neither IR key.
116
118
  */
117
119
  export const assembleChangeSetRegistrations = assembleCheckRegistrations;
118
- /** One stage's failure disposition: the stderr line, the recorded row, and exit 2. */
119
- function failClosed(telemetryPath, error) {
120
- process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
120
+ /** The message a thrown value carries, for a stderr line. */
121
+ function messageOf(error) {
122
+ return error instanceof Error ? error.message : String(error);
123
+ }
124
+ /**
125
+ * One stage's failure disposition: the stderr line, the recorded row, and exit 2. `suffix`
126
+ * is appended to the message, for a caller that can name the one call which would clear the
127
+ * failure.
128
+ */
129
+ function failClosed(telemetryPath, error, suffix) {
130
+ process.stderr.write(`covenant check failed closed: ${messageOf(error)}${suffix ?? ''}\n`);
121
131
  recordFailClosed(telemetryPath);
122
132
  return { exitCode: 2 };
123
133
  }
134
+ /** The observation, from the caller's value or from the thunk that produces it. */
135
+ function readInput(spec) {
136
+ return typeof spec.input === 'function' ? spec.input() : spec.input;
137
+ }
124
138
  /**
125
139
  * Settle the telemetry path and load the config once, or fail closed. The provisional
126
140
  * path is settled before the load so a config that never loads still has somewhere to
127
141
  * write its blocked row; both terms use `resolve` so a relative `repoRoot` cannot send
128
142
  * them to different files. The provisional term sits inside the try because `resolve`
129
143
  * throws on a non-string `repoRoot`.
144
+ *
145
+ * The load runs as its three steps rather than through `loadConfig`, so a failure carries
146
+ * how far it got: which file was discovered, and the bytes that file held.
130
147
  */
131
148
  function settleConfig(spec) {
132
149
  let telemetryPath;
150
+ let configPath;
151
+ let source;
133
152
  try {
134
153
  // The environment variable sits between the caller's path and the config's, matching
135
154
  // what the baseline comparison in this same process already resolves — the two write
@@ -138,13 +157,21 @@ function settleConfig(spec) {
138
157
  const envPath = process.env.POLYDEUKES_TELEMETRY_PATH;
139
158
  telemetryPath =
140
159
  spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
141
- const { config } = loadConfig({ rootDir: spec.repoRoot });
160
+ configPath = discoverConfigPath({ rootDir: spec.repoRoot });
161
+ source = readFileSync(join(spec.repoRoot, configPath), 'utf-8');
162
+ const { config } = parseConfigSource({ source, configPath });
142
163
  telemetryPath =
143
164
  spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
144
165
  return { settled: true, telemetryPath, config };
145
166
  }
146
167
  catch (error) {
147
- return { settled: false, ...failClosed(telemetryPath, error) };
168
+ return {
169
+ settled: false,
170
+ telemetryPath,
171
+ error,
172
+ ...(configPath === undefined ? {} : { configPath }),
173
+ ...(source === undefined ? {} : { source }),
174
+ };
148
175
  }
149
176
  }
150
177
  /**
@@ -312,6 +339,83 @@ function assertJudgeableShape(input) {
312
339
  throw new Error('input carries a session.channels.sidecar that is not text');
313
340
  }
314
341
  }
342
+ /**
343
+ * The disposition of a run whose config never loaded.
344
+ *
345
+ * While the one discovered file does not load there is no assembly to judge against, so the
346
+ * session surface admits exactly one shape: a single tool call whose file-change evidence
347
+ * modifies that file, starting from the bytes the loader read, and whose `post` is a text the
348
+ * loader accepts. That call is judged — its result loads — and lands as one `advised` row
349
+ * naming the config path, so the next call's baseline comparison reads the change as
350
+ * explained. Everything else fails closed, and where a single file was discovered the line
351
+ * says which call would repair it.
352
+ *
353
+ * This branch reads no posture: the session hook always spawns with `--enforce block`, so a
354
+ * repair that blocked under that posture would never run anywhere.
355
+ */
356
+ function settleLoadFailure(spec, failure) {
357
+ const { telemetryPath, error, configPath } = failure;
358
+ if (configPath === undefined || spec.surface !== 'session') {
359
+ return failClosed(telemetryPath, error);
360
+ }
361
+ let input;
362
+ try {
363
+ input = readInput(spec);
364
+ }
365
+ catch (inputError) {
366
+ return failClosed(telemetryPath, inputError);
367
+ }
368
+ const loaded = repairs(input, failure, spec.repoRoot);
369
+ if (loaded !== null) {
370
+ // The repaired config's own log path, under the precedence `settleConfig` uses, so the
371
+ // next call's baseline comparison reads this row where it looks for it.
372
+ const rowPath = spec.telemetryPath ??
373
+ process.env.POLYDEUKES_TELEMETRY_PATH ??
374
+ resolve(spec.repoRoot, loaded.config.telemetry.logPath);
375
+ appendRecordFailOpen(rowPath, {
376
+ event: 'advised',
377
+ label: 'covenant-check',
378
+ subject: configPath,
379
+ });
380
+ process.stderr.write(`covenant check: ${configPath} does not load (${messageOf(error)}) — this call rewrites it into one that does; advised, not judged\n`);
381
+ return { exitCode: 0 };
382
+ }
383
+ return failClosed(telemetryPath, error, ` — fix ${configPath} in one Edit or Write whose result loads; every other call stays blocked until it does`);
384
+ }
385
+ /**
386
+ * The loaded config a single call would leave behind, or null when this observation is not
387
+ * that call: exactly one call carrying a plain object, its evidence a modification of the
388
+ * discovered config file (relativized against the root, since a host names its paths
389
+ * absolutely), starting from the bytes the loader read, and leaving a `post` the loader
390
+ * accepts. Requiring the pre to be those bytes keeps a partial view of the file — one
391
+ * notebook cell, or evidence a caller composed — out of the branch.
392
+ */
393
+ function repairs(input, failure, repoRoot) {
394
+ const { configPath, source } = failure;
395
+ if (configPath === undefined || source === undefined)
396
+ return null;
397
+ if (!Array.isArray(input.toolCalls) || input.toolCalls.length !== 1)
398
+ return null;
399
+ const call = input.toolCalls[0];
400
+ if (!isPlainObject(call))
401
+ return null;
402
+ const { fileChange } = call;
403
+ if (fileChange === undefined || fileChange.kind !== 'modify')
404
+ return null;
405
+ if (relativizeForScope(fileChange.path, repoRoot) !== configPath)
406
+ return null;
407
+ if (fileChange.pre !== source)
408
+ return null;
409
+ const post = fileChange.post;
410
+ if (typeof post !== 'string')
411
+ return null;
412
+ try {
413
+ return parseConfigSource({ source: post, configPath });
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
315
419
  /**
316
420
  * Judge one observation of `repoRoot` exactly as the session surface would, from the IR the
317
421
  * caller hands in. Async because the dispatcher spawns covenant bodies. An input with no
@@ -326,11 +430,11 @@ function assertJudgeableShape(input) {
326
430
  export async function runCovenantCheck(spec) {
327
431
  const settlement = settleConfig(spec);
328
432
  if (!settlement.settled)
329
- return { exitCode: settlement.exitCode };
433
+ return settleLoadFailure(spec, settlement);
330
434
  const { telemetryPath, config } = settlement;
331
435
  let input;
332
436
  try {
333
- input = typeof spec.input === 'function' ? spec.input() : spec.input;
437
+ input = readInput(spec);
334
438
  // The world axis is this root's to fill. An input that supplies its own would let a
335
439
  // caller choose the files the judge reads.
336
440
  if ('world' in input) {
@@ -10,7 +10,7 @@
10
10
  | 하려는 것 | 읽을 문서 |
11
11
  |---|---|
12
12
  | 첫 눈에 보이는 판정을 보고 싶다 | [첫 판정](./tutorials/first-judgment.ko.md) — 설치하고 Claude Code를 배선한 뒤 보호된 편집이 판정되는 모습을 봅니다 |
13
- | Claude Code, Grok, git을 연결하고 싶다 | [표면 연결하기](./how-to/connect-surfaces.ko.md) — 세션과 커밋 배선, Grok 포함 |
13
+ | Claude Code, Grok, Codex, git을 연결하고 싶다 | [표면 연결하기](./how-to/connect-surfaces.ko.md) — 세션과 커밋 배선, Grok Codex 포함 |
14
14
  | 프로젝트 설정을 다듬고 싶다 | [프로젝트 설정하기](./how-to/configure-project.ko.md) — 발견, IDE 지원, advise와 block의 선택 |
15
15
  | 실제 규율을 쓰고 싶다 | [규율 작성하기](./how-to/write-disciplines.ko.md) — 실전 선언 예제, 특히 locale key pairing |
16
16
  | 차단이나 건너뜀에서 회복하고 싶다 | [문제 해결](./troubleshooting.ko.md) — fail-closed 상태, 증인 밸브, 로그 |
@@ -29,6 +29,7 @@
29
29
  | [`@polydeukes/core`](./reference/packages/core.ko.md) | 프로토콜, 입력 IR, 설정 스키마, 텔레메트리 |
30
30
  | [`@polydeukes/adapter-claude-code`](./reference/packages/adapter-claude-code.ko.md) | Claude Code 세션 표면입니다. 훅 페이로드에서 입력 IR로 |
31
31
  | [`@polydeukes/adapter-grok`](./reference/packages/adapter-grok.ko.md) | Grok 세션 표면입니다. 훅 페이로드에서 입력 IR로 |
32
+ | [`@polydeukes/adapter-codex`](./reference/packages/adapter-codex.ko.md) | Codex 세션 표면입니다. 훅 페이로드에서 입력 IR로, 패치가 건드리는 파일마다 원소 하나 |
32
33
 
33
34
  <a id="shape-of-the-thing"></a>
34
35
  ## 한 페이지로 보는 구조
@@ -52,7 +53,7 @@
52
53
 
53
54
  | 표면 | 판정 대상 | 배선 방법 | 대상 |
54
55
  |---|---|---|---|
55
- | **세션** | 도구 호출, 실행되기 전에 | `pdks-claude-code init` 또는 `pdks-grok init` | AI 파트너와 함께 개발하는 프로젝트 |
56
+ | **세션** | 도구 호출, 실행되기 전에 | `pdks-claude-code init`, `pdks-grok init`, 또는 `pdks-codex init` | AI 파트너와 함께 개발하는 프로젝트 |
56
57
  | **커밋** | stdin의 unified diff — 스테이징 영역, 작업 트리, ref 범위 | `git diff --cached`를 파이프로 넘기는 pre-commit 훅, 또는 필요할 때 직접 실행 | 혼자 개발하는 사람, 그리고 CI |
57
58
 
58
59
  커밋 판정기는 필요할 때 직접 실행할 수도 있습니다. 작업 후에는 `git diff HEAD | pdks covenant check --diff`,
@@ -11,7 +11,7 @@ question is.
11
11
  | If you want to | Read |
12
12
  |---|---|
13
13
  | Get to a first visible judgment | [First judgment](./tutorials/first-judgment.md) — install, wire Claude Code, and watch one protected edit get judged |
14
- | Connect Claude Code, Grok, or git | [Connect the surfaces](./how-to/connect-surfaces.md) — session and commit setup, including Grok |
14
+ | Connect Claude Code, Grok, Codex, or git | [Connect the surfaces](./how-to/connect-surfaces.md) — session and commit setup, including Grok and Codex |
15
15
  | Shape the project config | [Configure the project](./how-to/configure-project.md) — discovery, IDE support, and advise versus block |
16
16
  | Write a real discipline | [Write disciplines](./how-to/write-disciplines.md) — worked declarations, especially locale key pairing |
17
17
  | Recover from a block or skip | [Troubleshooting](./troubleshooting.md) — the fail-closed states, the witness valve, and the log |
@@ -31,6 +31,7 @@ is aspirational.
31
31
  | [`@polydeukes/core`](./reference/packages/core.md) | The protocol, the input IR, the config schema, telemetry |
32
32
  | [`@polydeukes/adapter-claude-code`](./reference/packages/adapter-claude-code.md) | Claude Code session surface — hook payloads become the input IR |
33
33
  | [`@polydeukes/adapter-grok`](./reference/packages/adapter-grok.md) | Grok session surface — hook payloads become the input IR |
34
+ | [`@polydeukes/adapter-codex`](./reference/packages/adapter-codex.md) | Codex session surface — hook payloads become the input IR, one element per file the patch touches |
34
35
 
35
36
  <a id="shape-of-the-thing"></a>
36
37
  ## The shape of the thing, in one page
@@ -56,7 +57,7 @@ in the whitepaper, which were all found by counting rows rather than by reading
56
57
 
57
58
  | Surface | Judges | Wired by | For |
58
59
  |---|---|---|---|
59
- | **Session** | A tool call, before it runs | `pdks-claude-code init` or `pdks-grok init` | A project developed with an AI partner |
60
+ | **Session** | A tool call, before it runs | `pdks-claude-code init`, `pdks-grok init`, or `pdks-codex init` | A project developed with an AI partner |
60
61
  | **Commit** | A unified diff on stdin — staged, the working tree, or a ref range | A pre-commit hook piping `git diff --cached`, or run on demand | A human developing alone, and CI |
61
62
 
62
63
  The commit judge also answers on demand: `git diff HEAD | pdks covenant check --diff` after a task,
@@ -266,6 +266,22 @@
266
266
  "summary": "Grok 세션 표면을 설치하고, 어댑터 패키지의 계약과 한계를 확인합니다."
267
267
  }
268
268
  },
269
+ {
270
+ "id": "package-adapter-codex",
271
+ "category": "reference",
272
+ "order": 17,
273
+ "bundled": true,
274
+ "en": {
275
+ "path": "reference/packages/adapter-codex.md",
276
+ "title": "`@polydeukes/adapter-codex`",
277
+ "summary": "Install the Codex session surface, and consult how patch text becomes file-change evidence."
278
+ },
279
+ "ko": {
280
+ "path": "reference/packages/adapter-codex.ko.md",
281
+ "title": "`@polydeukes/adapter-codex`",
282
+ "summary": "Codex 세션 표면을 설치하고, 패치 텍스트가 파일 변경 증거가 되는 방식을 확인합니다."
283
+ }
284
+ },
269
285
  {
270
286
  "id": "package-sdk-ts",
271
287
  "category": "reference",
@@ -425,6 +441,22 @@
425
441
  "title": "<!--",
426
442
  "summary": "당시의 개발 기록이며 현재 사용법을 설명하는 참조 문서는 아닙니다."
427
443
  }
444
+ },
445
+ {
446
+ "id": "post-8",
447
+ "category": "history",
448
+ "order": 25,
449
+ "bundled": false,
450
+ "en": {
451
+ "path": "build-in-public/2026-09-v0.7-entrypoint-reshape.md",
452
+ "title": "<!--",
453
+ "summary": "Historical development account; not a current usage reference."
454
+ },
455
+ "ko": {
456
+ "path": "build-in-public/2026-09-v0.7-entrypoint-reshape.ko.md",
457
+ "title": "<!--",
458
+ "summary": "당시의 개발 기록이며 현재 사용법을 설명하는 참조 문서는 아닙니다."
459
+ }
428
460
  }
429
461
  ],
430
462
  "topics": {