polydeukes 0.6.0 → 0.7.1

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 (117) hide show
  1. package/README.ko.md +30 -70
  2. package/README.md +31 -84
  3. package/dist/baseline.d.ts +37 -1
  4. package/dist/baseline.js +68 -1
  5. package/dist/bin.d.ts +3 -4
  6. package/dist/bin.js +79 -93
  7. package/dist/covenant/bash-line.d.ts +130 -0
  8. package/dist/covenant/bash-line.js +566 -0
  9. package/dist/covenant/declaration-engine.d.ts +108 -0
  10. package/dist/covenant/declaration-engine.js +415 -0
  11. package/dist/covenant/discipline.d.ts +103 -0
  12. package/dist/covenant/discipline.js +667 -0
  13. package/dist/covenant/dispatch.d.ts +153 -0
  14. package/dist/covenant/dispatch.js +237 -0
  15. package/dist/covenant/extract-steps.d.ts +122 -0
  16. package/dist/covenant/extract-steps.js +0 -0
  17. package/dist/covenant/mention.d.ts +117 -0
  18. package/dist/covenant/mention.js +209 -0
  19. package/dist/covenant/module.d.ts +25 -0
  20. package/dist/covenant/module.js +23 -0
  21. package/dist/covenant/mutation-rules.d.ts +40 -0
  22. package/dist/covenant/mutation-rules.js +146 -0
  23. package/dist/covenant/relations.d.ts +46 -0
  24. package/dist/covenant/relations.js +68 -0
  25. package/dist/covenant/run-covenant.d.ts +95 -0
  26. package/dist/covenant/run-covenant.js +164 -0
  27. package/dist/covenant/self-mod.d.ts +55 -0
  28. package/dist/covenant/self-mod.js +84 -0
  29. package/dist/covenant/shell-evidence.d.ts +38 -0
  30. package/dist/covenant/shell-evidence.js +247 -0
  31. package/dist/covenant/shell-mod.d.ts +84 -0
  32. package/dist/covenant/shell-mod.js +213 -0
  33. package/dist/covenant/supply.d.ts +54 -0
  34. package/dist/covenant/supply.js +50 -0
  35. package/dist/covenant/transcript-mod.d.ts +60 -0
  36. package/dist/covenant/transcript-mod.js +254 -0
  37. package/dist/covenant/ttl-witness.d.ts +47 -0
  38. package/dist/covenant/ttl-witness.js +80 -0
  39. package/dist/covenant-check.d.ts +72 -39
  40. package/dist/covenant-check.js +260 -121
  41. package/dist/diff-ir.d.ts +28 -0
  42. package/dist/diff-ir.js +310 -0
  43. package/dist/docs/README.ko.md +60 -0
  44. package/dist/docs/README.md +64 -0
  45. package/dist/docs/catalog.json +480 -0
  46. package/dist/docs/concepts/judgment.ko.md +113 -0
  47. package/dist/docs/concepts/judgment.md +113 -0
  48. package/dist/docs/how-to/configure-project.ko.md +99 -0
  49. package/dist/docs/how-to/configure-project.md +98 -0
  50. package/dist/docs/how-to/connect-surfaces.ko.md +120 -0
  51. package/dist/docs/how-to/connect-surfaces.md +121 -0
  52. package/dist/docs/how-to/write-disciplines.ko.md +179 -0
  53. package/dist/docs/how-to/write-disciplines.md +181 -0
  54. package/dist/docs/index.json +2280 -0
  55. package/dist/docs/reference/cli/covenant-check.ko.md +148 -0
  56. package/dist/docs/reference/cli/covenant-check.md +153 -0
  57. package/dist/docs/reference/cli/docs.ko.md +97 -0
  58. package/dist/docs/reference/cli/docs.md +95 -0
  59. package/dist/docs/reference/cli/explain.ko.md +84 -0
  60. package/dist/docs/reference/cli/explain.md +89 -0
  61. package/dist/docs/reference/cli/init.ko.md +119 -0
  62. package/dist/docs/reference/cli/init.md +119 -0
  63. package/dist/docs/reference/configuration/index.ko.md +553 -0
  64. package/dist/docs/reference/{configuration.md → configuration/index.md} +215 -90
  65. package/dist/docs/reference/packages/adapter-claude-code.ko.md +110 -0
  66. package/dist/docs/reference/{adapter-claude-code.md → packages/adapter-claude-code.md} +47 -19
  67. package/dist/docs/reference/packages/adapter-grok.ko.md +77 -0
  68. package/dist/docs/reference/packages/adapter-grok.md +79 -0
  69. package/dist/docs/reference/packages/core.ko.md +174 -0
  70. package/dist/docs/reference/{core.md → packages/core.md} +74 -12
  71. package/dist/docs/reference/packages/polydeukes.ko.md +195 -0
  72. package/dist/docs/reference/packages/polydeukes.md +204 -0
  73. package/dist/docs/reference/packages/sdk-ts.ko.md +161 -0
  74. package/dist/docs/reference/packages/sdk-ts.md +164 -0
  75. package/dist/docs/troubleshooting.ko.md +144 -0
  76. package/dist/docs/troubleshooting.md +103 -151
  77. package/dist/docs/tutorials/first-judgment.ko.md +82 -0
  78. package/dist/docs/tutorials/first-judgment.md +81 -0
  79. package/dist/docs-catalog.d.ts +25 -0
  80. package/dist/docs-catalog.js +450 -0
  81. package/dist/docs-library.d.ts +23 -0
  82. package/dist/docs-library.js +347 -0
  83. package/dist/docs-markdown.d.ts +32 -0
  84. package/dist/docs-markdown.js +150 -0
  85. package/dist/docs-query.d.ts +11 -40
  86. package/dist/docs-query.js +28 -122
  87. package/dist/docs-types.d.ts +105 -0
  88. package/dist/docs-types.js +2 -0
  89. package/dist/explain.d.ts +1 -1
  90. package/dist/explain.js +37 -22
  91. package/dist/load-config.d.ts +1 -1
  92. package/dist/load-config.js +1 -1
  93. package/dist/pre-state-reader.d.ts +1 -1
  94. package/dist/pre-state-reader.js +1 -1
  95. package/dist/scaffold-project.d.ts +2 -2
  96. package/dist/scaffold-project.js +9 -3
  97. package/dist/schema/polydeukes.schema.json +12 -0
  98. package/dist/worktree-reader.d.ts +19 -0
  99. package/dist/worktree-reader.js +30 -0
  100. package/package.json +3 -17
  101. package/dist/claude-code-hook.d.ts +0 -79
  102. package/dist/claude-code-hook.js +0 -372
  103. package/dist/claude-code.d.ts +0 -6
  104. package/dist/claude-code.js +0 -6
  105. package/dist/covenant-module.d.ts +0 -25
  106. package/dist/covenant-module.js +0 -42
  107. package/dist/docs/configuration.md +0 -103
  108. package/dist/docs/installation.md +0 -241
  109. package/dist/docs/reference/adapter-git.md +0 -100
  110. package/dist/docs/reference/covenant.md +0 -116
  111. package/dist/docs/reference/polydeukes.md +0 -315
  112. package/dist/index.d.ts +0 -22
  113. package/dist/index.js +0 -21
  114. package/dist/init-claude-code.d.ts +0 -52
  115. package/dist/init-claude-code.js +0 -469
  116. package/dist/init-grok.d.ts +0 -51
  117. package/dist/init-grok.js +0 -242
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `runCovenant` — the covenant execution wrapper.
3
+ *
4
+ * Calls an in-process judge thunk the assembly has already bound its payload and options
5
+ * into, translates the thunk's exit-code equivalent by policy (1 → blocking 2), writes the
6
+ * break reason to stderr, and appends exactly one telemetry record per call via
7
+ * {@link appendRecordFailOpen} (the core's fail-open wrapper around its sole collector —
8
+ * no local logger). {@link translateExitCode} is pure.
9
+ */
10
+ import { appendRecordFailOpen, EXIT_BREAK_BLOCKING, EXIT_BREAK_NON_BLOCKING, EXIT_UPHOLD, } from '@polydeukes/core';
11
+ /**
12
+ * How many witness elements one break contributes to a telemetry row. A relation over a
13
+ * large file can return thousands, and the row is one line — the true count rides beside
14
+ * the truncated list rather than being lost with it.
15
+ */
16
+ const WITNESSES_PER_BREAK = 8;
17
+ /**
18
+ * How many characters of one witness value the row keeps. A witness over a bare `source`
19
+ * step is the whole file, and the row is one line — the value is cut with its true length
20
+ * beside it rather than dropped.
21
+ */
22
+ const WITNESS_VALUE_CHARS = 200;
23
+ /** A witness whose serialized value fits the row; a longer one is cut and says so. */
24
+ function boundedWitness(witness) {
25
+ const serialized = JSON.stringify(witness.value);
26
+ if (serialized === undefined || serialized.length <= WITNESS_VALUE_CHARS)
27
+ return witness;
28
+ return {
29
+ ...witness,
30
+ value: serialized.slice(0, WITNESS_VALUE_CHARS),
31
+ truncated: serialized.length,
32
+ };
33
+ }
34
+ /**
35
+ * Serialize a body's breaks into the row's fifth field: id, capped witnesses, true total —
36
+ * or nothing when a value cannot be serialized. Telemetry is fail-open, so a witness
37
+ * carrying a value `JSON.stringify` refuses costs the row its fifth field, never the verdict.
38
+ */
39
+ function serializeWitnesses(breaks) {
40
+ try {
41
+ return JSON.stringify(breaks.map((entry) => ({
42
+ id: entry.id,
43
+ witnesses: entry.witnesses.slice(0, WITNESSES_PER_BREAK).map(boundedWitness),
44
+ total: entry.witnesses.length,
45
+ })));
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Translate a body outcome into the wrapper verdict and telemetry event (pure).
53
+ *
54
+ * `bodyExitCode === 0` (uphold) passes; every other outcome — a break report (`1`), the
55
+ * body's own fail-closed (`2`), any uninterpretable code (`3+`), or a body that answered
56
+ * nothing interpretable (`null`) — is fail-closed to the blocking `2` / `blocked`. The
57
+ * unconditional 1→2 translation lives here, isolated, so it has one place to evolve in.
58
+ *
59
+ * `enforce` relaxes ONLY the verdict cell: under `advise` a break report (`1`) becomes
60
+ * `0` / `advised` — recorded, not blocking. Every unjudgeable outcome (`2`, `3+`, `null`)
61
+ * stays `2` / `blocked` regardless of level.
62
+ */
63
+ export function translateExitCode(bodyExitCode, enforce = 'block') {
64
+ if (bodyExitCode === EXIT_UPHOLD) {
65
+ return { exitCode: EXIT_UPHOLD, event: 'passed' };
66
+ }
67
+ if (enforce === 'advise' && bodyExitCode === EXIT_BREAK_NON_BLOCKING) {
68
+ return { exitCode: EXIT_UPHOLD, event: 'advised' };
69
+ }
70
+ return { exitCode: EXIT_BREAK_BLOCKING, event: 'blocked' };
71
+ }
72
+ /**
73
+ * Run the judge thunk and normalize what it answers.
74
+ *
75
+ * A throw is the body-crash cell, so crash isolation is this try/catch. A resolution that
76
+ * is not the outcome shape (a stale dist answering an older contract) is uninterpretable
77
+ * and lands in the same cell: `null` routes to the translation table's fail-closed row
78
+ * without that table learning a new code.
79
+ */
80
+ async function runBody(body) {
81
+ let outcome;
82
+ try {
83
+ outcome = await body();
84
+ }
85
+ catch {
86
+ return { exitCode: EXIT_BREAK_BLOCKING };
87
+ }
88
+ if (typeof outcome !== 'object' || outcome === null) {
89
+ return { exitCode: EXIT_BREAK_BLOCKING };
90
+ }
91
+ return outcome;
92
+ }
93
+ /** Turn a pure judge's verdict into the outcome a thunk answers. */
94
+ export function outcomeFromVerdict(verdict) {
95
+ return verdict.upheld
96
+ ? { exitCode: EXIT_UPHOLD }
97
+ : { exitCode: EXIT_BREAK_NON_BLOCKING, reason: verdict.reason };
98
+ }
99
+ /** The unjudgeable outcome: a misassembly or an input no judge could read (`2`, no reason). */
100
+ export const UNJUDGEABLE_OUTCOME = { exitCode: EXIT_BREAK_BLOCKING };
101
+ /** Consult the valve, counting a throw as closed — an uncertain valve never opens. */
102
+ function witnessOpens(witness) {
103
+ try {
104
+ return witness() === true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * Run a covenant body through the wrapper.
112
+ *
113
+ * The order is judge → translate → valve: the body always runs, and only a `blocked`
114
+ * translation has anything for the valve to relax into `0` / `witnessed`. Whatever that
115
+ * leaves is recorded ONCE — one call, one row — so a witnessed break never leaves a
116
+ * `blocked` row beside its `witnessed` one.
117
+ *
118
+ * The break reason goes to stderr whenever the thunk carried one, whatever the level and
119
+ * whatever the final event: gating it on the verdict would leave `advised` mute and the
120
+ * valve silent about what it opened.
121
+ *
122
+ * A body that answers `skipped` evaluated no relation, so it never reaches the translation
123
+ * table or the valve: the row says so and the call upholds.
124
+ *
125
+ * Resolves with the wrapper's final `exitCode` (`0` or `2`) and the telemetry `event` that
126
+ * was recorded. The event is surfaced rather than left to callers: the valve is impure, so
127
+ * recomputing the event would consult it a second time. Logging is fail-open
128
+ * via {@link appendRecordFailOpen}: a telemetry failure never alters the verdict and never
129
+ * throws. The gate closes; the measurement stays open.
130
+ */
131
+ export async function runCovenant(spec) {
132
+ const outcome = await runBody(spec.body);
133
+ // A code the outcome does not carry as a number is uninterpretable, and `null` is the
134
+ // table's existing cell for exactly that — no new row in translateExitCode.
135
+ const bodyExitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null;
136
+ if (typeof outcome.reason === 'string' && outcome.reason !== '') {
137
+ process.stderr.write(`${outcome.reason}\n`);
138
+ }
139
+ // A skip token beside a non-zero code is a body contradicting itself; the break wins, so a
140
+ // verdict is never downgraded by a stray token.
141
+ if (outcome.skipped !== undefined && bodyExitCode === EXIT_UPHOLD) {
142
+ appendRecordFailOpen(spec.telemetryPath, {
143
+ event: 'skipped',
144
+ label: spec.label,
145
+ subject: spec.subject ?? '-',
146
+ reason: outcome.skipped,
147
+ });
148
+ return { exitCode: EXIT_UPHOLD, event: 'skipped' };
149
+ }
150
+ const verdict = translateExitCode(bodyExitCode, spec.enforce);
151
+ const { exitCode, event } = verdict.event === 'blocked' && spec.witness !== undefined && witnessOpens(spec.witness)
152
+ ? { exitCode: EXIT_UPHOLD, event: 'witnessed' }
153
+ : verdict;
154
+ const serialized = Array.isArray(outcome.witnesses)
155
+ ? serializeWitnesses(outcome.witnesses)
156
+ : undefined;
157
+ appendRecordFailOpen(spec.telemetryPath, {
158
+ event,
159
+ label: spec.label,
160
+ subject: spec.subject ?? '-',
161
+ ...(serialized !== undefined && { witnesses: serialized }),
162
+ });
163
+ return { exitCode, event };
164
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `judgeSelfModification` — the self-mod meta-covenant's pure judge (zero I/O).
3
+ *
4
+ * Breaks when a *mutating* tool call (a `name` exactly equal to an injected entry of
5
+ * `mutatingToolNames`) targets a protected path. The target is read from the call's own
6
+ * nested `fileChange` evidence when it carries one, and only otherwise from an `args`
7
+ * mention traversal. It judges only its own axis: a non-mutating tool call that merely
8
+ * mentions a protected path is upheld — that path belongs to the Bash meta-covenant, and
9
+ * run-all co-existence depends on this boundary. Tool names and paths are injected values,
10
+ * never source literals.
11
+ */
12
+ import type { CovenantInput, CovenantVerdict } from '@polydeukes/core';
13
+ import type { CovenantRegistration, MetaCovenantRegistration } from './dispatch.ts';
14
+ /**
15
+ * `SelfModificationSpec` — the injected axes of the judge.
16
+ *
17
+ * `protectedPaths` are literal path strings; `mutatingToolNames` are the tool names that
18
+ * count as mutating. Empty-string entries in either list are ignored (an unguarded `''`
19
+ * would match every path / every tool).
20
+ */
21
+ export type SelfModificationSpec = {
22
+ protectedPaths: string[];
23
+ mutatingToolNames: string[];
24
+ };
25
+ /**
26
+ * Judge a {@link CovenantInput} against the self-mod spec (pure).
27
+ *
28
+ * A call whose `name` is a non-empty `mutatingToolNames` entry is judged on its proven
29
+ * `fileChange` target when it carries one ({@link pathMatchesProtected} segment semantics,
30
+ * every kind including `delete`), and otherwise on an `args` mention traversal at any
31
+ * depth. Evidence, when attached, must be the call's complete mutation-target set.
32
+ *
33
+ * @param input - The call set to judge
34
+ * @param spec - Protected paths and mutating tool names; empty strings are ignored
35
+ * @returns A break naming the tool and the proven or mentioned path, or an uphold
36
+ */
37
+ export declare function judgeSelfModification(input: CovenantInput, spec: SelfModificationSpec): CovenantVerdict;
38
+ /**
39
+ * `SelfModRegistrationSpec` — the assembly values baked into the registration. The call
40
+ * set is not among them: the dispatcher supplies it to the judge at call time, so one
41
+ * built registration serves every payload.
42
+ */
43
+ export type SelfModRegistrationSpec = {
44
+ protectedPaths: string[];
45
+ mutatingToolNames: string[];
46
+ witness?: CovenantRegistration['witness'];
47
+ };
48
+ /**
49
+ * Build the self-mod registration. Routing stays path mention; the judgment is the thunk.
50
+ *
51
+ * The misassembly gate lives at the thunk's entry: zero valid entries in either list would
52
+ * make {@link judgeSelfModification} uphold every call, so it answers the unjudgeable
53
+ * outcome instead, which no enforce level softens.
54
+ */
55
+ export declare function selfModRegistration(spec: SelfModRegistrationSpec): MetaCovenantRegistration;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `judgeSelfModification` — the self-mod meta-covenant's pure judge (zero I/O).
3
+ *
4
+ * Breaks when a *mutating* tool call (a `name` exactly equal to an injected entry of
5
+ * `mutatingToolNames`) targets a protected path. The target is read from the call's own
6
+ * nested `fileChange` evidence when it carries one, and only otherwise from an `args`
7
+ * mention traversal. It judges only its own axis: a non-mutating tool call that merely
8
+ * mentions a protected path is upheld — that path belongs to the Bash meta-covenant, and
9
+ * run-all co-existence depends on this boundary. Tool names and paths are injected values,
10
+ * never source literals.
11
+ */
12
+ import { mentionsPath, pathMatchesProtected, provenChangePath } from './mention.js';
13
+ import { outcomeFromVerdict, UNJUDGEABLE_OUTCOME } from './run-covenant.js';
14
+ /**
15
+ * Judge a {@link CovenantInput} against the self-mod spec (pure).
16
+ *
17
+ * A call whose `name` is a non-empty `mutatingToolNames` entry is judged on its proven
18
+ * `fileChange` target when it carries one ({@link pathMatchesProtected} segment semantics,
19
+ * every kind including `delete`), and otherwise on an `args` mention traversal at any
20
+ * depth. Evidence, when attached, must be the call's complete mutation-target set.
21
+ *
22
+ * @param input - The call set to judge
23
+ * @param spec - Protected paths and mutating tool names; empty strings are ignored
24
+ * @returns A break naming the tool and the proven or mentioned path, or an uphold
25
+ */
26
+ export function judgeSelfModification(input, spec) {
27
+ const mutatingNames = spec.mutatingToolNames.filter((name) => name !== '');
28
+ const protectedPaths = spec.protectedPaths.filter((path) => path !== '');
29
+ for (const call of input.toolCalls) {
30
+ if (!mutatingNames.includes(call.name)) {
31
+ continue;
32
+ }
33
+ const changePath = provenChangePath(call);
34
+ if (changePath !== null) {
35
+ if (protectedPaths.some((path) => pathMatchesProtected(changePath, path))) {
36
+ return {
37
+ upheld: false,
38
+ reason: `${call.name} would modify protected path ${changePath}`,
39
+ };
40
+ }
41
+ continue;
42
+ }
43
+ const mentioned = protectedPaths.find((path) => mentionsPath(call.args, path));
44
+ if (mentioned !== undefined) {
45
+ return {
46
+ upheld: false,
47
+ reason: `${call.name} would modify protected path ${mentioned}`,
48
+ };
49
+ }
50
+ }
51
+ return { upheld: true };
52
+ }
53
+ /**
54
+ * Build the self-mod registration. Routing stays path mention; the judgment is the thunk.
55
+ *
56
+ * The misassembly gate lives at the thunk's entry: zero valid entries in either list would
57
+ * make {@link judgeSelfModification} uphold every call, so it answers the unjudgeable
58
+ * outcome instead, which no enforce level softens.
59
+ */
60
+ export function selfModRegistration(spec) {
61
+ const judgeSpec = {
62
+ protectedPaths: spec.protectedPaths,
63
+ mutatingToolNames: spec.mutatingToolNames,
64
+ };
65
+ return {
66
+ label: 'self-mod',
67
+ protectedPaths: spec.protectedPaths,
68
+ body: async (input) => {
69
+ if (judgeSpec.protectedPaths.filter((path) => path !== '').length === 0 ||
70
+ judgeSpec.mutatingToolNames.filter((name) => name !== '').length === 0) {
71
+ return UNJUDGEABLE_OUTCOME;
72
+ }
73
+ try {
74
+ return outcomeFromVerdict(judgeSelfModification(input, judgeSpec));
75
+ }
76
+ catch {
77
+ // Structurally unjudgeable input that passed parseInput (which validates the
78
+ // collection shapes, not the element ones): cannot judge means block.
79
+ return UNJUDGEABLE_OUTCOME;
80
+ }
81
+ },
82
+ ...(spec.witness !== undefined ? { witness: spec.witness } : {}),
83
+ };
84
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shell-evidence derivation — one command line in, computed writes plus a reasoned
3
+ * unjudgeable list out.
4
+ *
5
+ * Pure: no disk read, no spawn. `create`/`modify` discrimination and the append composition
6
+ * need a pre-state, which belongs to the judged body; this layer answers only what the
7
+ * command text itself decides.
8
+ *
9
+ * The enumerated forms below ARE the contract — a form none of them names is unjudgeable,
10
+ * never a confident guess. An uncomputable write whose target is known carries that path,
11
+ * so the compiler can route it to the discipline whose scope covers it; a write whose
12
+ * target is unknowable carries none. A command with no mutation signal at all leaves both
13
+ * lists empty: recording a line per read would drown the log it feeds.
14
+ */
15
+ /** A computed write: the target, the exact bytes written, and the redirect direction. */
16
+ export type ShellChange = {
17
+ path: string;
18
+ content: string;
19
+ mode: 'truncate' | 'append';
20
+ };
21
+ /** A detected write that cannot be computed; `path` is present only when the target is known. */
22
+ export type ShellUnjudgeable = {
23
+ path?: string;
24
+ reason: string;
25
+ };
26
+ /** The derivation of one command line: computed writes and detected-but-uncomputable ones. */
27
+ export type ShellDerivation = {
28
+ evidence: ShellChange[];
29
+ unjudgeable: ShellUnjudgeable[];
30
+ };
31
+ /**
32
+ * Derive the file changes one shell command line proves.
33
+ *
34
+ * Never throws: a span the tokenizer could not read is exactly where a quiet pass would
35
+ * hide, so each one answers an unjudgeable entry carrying its reason — and the commands
36
+ * around it still contribute their real evidence.
37
+ */
38
+ export declare function deriveShellChanges(commandLine: string): ShellDerivation;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Shell-evidence derivation — one command line in, computed writes plus a reasoned
3
+ * unjudgeable list out.
4
+ *
5
+ * Pure: no disk read, no spawn. `create`/`modify` discrimination and the append composition
6
+ * need a pre-state, which belongs to the judged body; this layer answers only what the
7
+ * command text itself decides.
8
+ *
9
+ * The enumerated forms below ARE the contract — a form none of them names is unjudgeable,
10
+ * never a confident guess. An uncomputable write whose target is known carries that path,
11
+ * so the compiler can route it to the discipline whose scope covers it; a write whose
12
+ * target is unknowable carries none. A command with no mutation signal at all leaves both
13
+ * lists empty: recording a line per read would drown the log it feeds.
14
+ */
15
+ import { commandNameWord, isNestedShellCommand, tokenizeCommandLine, } from './bash-line.js';
16
+ import { commandBasename, isFdReference, redirectWriteRule, sedInPlaceRule, teeRule, } from './mutation-rules.js';
17
+ import { DEFAULT_READ_ONLY_COMMANDS } from './shell-mod.js';
18
+ // A command that moves the working directory makes every relative target on the line
19
+ // ambiguous — resolving it against a guessed base is exactly the approximation this layer
20
+ // refuses to make.
21
+ const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd']);
22
+ // The redirect spellings that carry stdout — the only stream whose bytes this layer can
23
+ // compute. Every member is load-bearing: `1>` IS `>` by fd, and `>|`/`1>|` are write
24
+ // operators the tokenizer emits and the detection rules grade by the `>` they contain.
25
+ // Dropping any of them files a computable write as `does not carry stdout`, so the evidence
26
+ // axis would refuse a write the judging axis already accepted.
27
+ const STDOUT_WRITE_OPERATORS = new Set(['>', '>>', '>|', '1>', '1>>', '1>|']);
28
+ // Rules that detect a write with no redirect. The redirect rule is consulted separately,
29
+ // for its fd-reference boundary (`2>&1` is neither a write nor a signal).
30
+ const DETECTION_RULES = [teeRule, sedInPlaceRule];
31
+ /** True when the command's leading words match a read-only allowlist entry's sequence. */
32
+ function isReadOnlyHead(command) {
33
+ return DEFAULT_READ_ONLY_COMMANDS.some((entry) => entry.split(' ').every((entryWord, k) => {
34
+ const word = command.words[k];
35
+ if (word === undefined || word.opaque)
36
+ return false;
37
+ // The first word is compared by basename (`/bin/cat` is still `cat`), later ones verbatim.
38
+ return (k === 0 ? commandBasename(word) : word.text) === entryWord;
39
+ }));
40
+ }
41
+ /**
42
+ * The write-direction redirects of a command. The redirect rule owns the fd-reference
43
+ * boundary, so its detections are the SSOT for what counts as a write; an opaque target
44
+ * keeps its redirect (the rule stays silent on unknowable values, but the write signal
45
+ * is real).
46
+ */
47
+ function writeRedirects(command) {
48
+ const detected = new Set(redirectWriteRule.detect(command).map((target) => target.path));
49
+ return command.redirects.filter((redirect) => redirect.operator.includes('>') &&
50
+ // An fd duplication is never a write, even when its digit equals another write's
51
+ // filename (`> 1 2>&1`) — without this the digit re-admits it via `detected`.
52
+ !(redirect.operator.endsWith('>&') && isFdReference(redirect.target.text)) &&
53
+ (redirect.target.opaque || detected.has(redirect.target.text)));
54
+ }
55
+ /** The path a target text names, or the reason this layer cannot know it. */
56
+ function resolveTarget(text, opaque, movesDirectory) {
57
+ if (opaque)
58
+ return { reason: `write target ${text} is opaque` };
59
+ // `~` is never expanded here — the same contract the path-notation judge keeps.
60
+ if (text.startsWith('~'))
61
+ return { reason: `write target ${text} is home-relative` };
62
+ if (movesDirectory && !text.startsWith('/')) {
63
+ return { reason: `relative write target ${text} beside a directory change` };
64
+ }
65
+ return { path: text };
66
+ }
67
+ /** File an unjudgeable entry for a target, carrying its path only when one is known. */
68
+ function fileTarget(target, reason) {
69
+ return 'path' in target ? { path: target.path, reason } : { reason: target.reason };
70
+ }
71
+ /** The bytes an `echo` writes: its arguments joined, plus the newline echo appends. */
72
+ function echoContent(command) {
73
+ const args = command.words.slice(1);
74
+ // Flag semantics (`-n`, `-e`) are not computed — the first argument decides, so a later
75
+ // dash-argument is ordinary content.
76
+ if (args[0]?.text.startsWith('-'))
77
+ return { reason: 'echo flag semantics are not computed' };
78
+ if (args.some((word) => word.opaque))
79
+ return { reason: 'an echo argument is opaque' };
80
+ return { content: `${args.map((word) => word.text).join(' ')}\n` };
81
+ }
82
+ /** The bytes a command copying its stdin writes (heredoc, herestring, or a file read). */
83
+ function stdinContent(command, reads) {
84
+ const first = command.words[0];
85
+ // Only a bare `cat` copies stdin to the redirect verbatim; anything else transforms it.
86
+ if (first === undefined || first.opaque || command.words.length > 1) {
87
+ return { reason: 'stdin is transformed by the command, not copied verbatim' };
88
+ }
89
+ if (commandBasename(first) !== 'cat') {
90
+ return { reason: `stdin of ${first.text} is not copied verbatim` };
91
+ }
92
+ const heredocs = command.heredocs ?? [];
93
+ if (heredocs.length + reads.length > 1)
94
+ return { reason: 'more than one stdin source' };
95
+ const heredoc = heredocs[0];
96
+ if (heredoc !== undefined) {
97
+ // Backslash joins the escape set: in an unquoted heredoc bash processes `\` too
98
+ // (a trailing one is a line continuation), so captured bytes would be fiction.
99
+ if (!heredoc.literal && /[$`\\]/.test(heredoc.body)) {
100
+ return { reason: 'an unquoted heredoc body carries an expansion or escape' };
101
+ }
102
+ return { content: heredoc.body };
103
+ }
104
+ const read = reads[0];
105
+ if (read === undefined)
106
+ return { reason: 'no stdin source to read' };
107
+ if (read.operator !== '<<<')
108
+ return { reason: `stdin comes from file ${read.target.text}` };
109
+ if (read.target.opaque)
110
+ return { reason: 'the herestring is opaque' };
111
+ return { content: `${read.target.text}\n` };
112
+ }
113
+ /** The bytes one command writes to its stdout redirect. */
114
+ function computeContent(command) {
115
+ // With no command word the redirection still runs and nothing writes into it.
116
+ if (command.words.length === 0)
117
+ return { content: '' };
118
+ const reads = command.redirects.filter((redirect) => !redirect.operator.includes('>'));
119
+ if (reads.length > 0 || (command.heredocs?.length ?? 0) > 0) {
120
+ return stdinContent(command, reads);
121
+ }
122
+ const first = command.words[0];
123
+ if (first === undefined || first.opaque)
124
+ return { reason: 'the writing command is opaque' };
125
+ if (commandBasename(first) !== 'echo') {
126
+ return { reason: `the output of ${first.text} is not computable` };
127
+ }
128
+ return echoContent(command);
129
+ }
130
+ /** Derive one command's write redirects into evidence or unjudgeable entries. */
131
+ function deriveWrites(command, writes, movesDirectory, derivation) {
132
+ // Two writes on one command mean two results, and computing either alone is wrong.
133
+ if (writes.length > 1) {
134
+ for (const redirect of writes) {
135
+ const target = resolveTarget(redirect.target.text, redirect.target.opaque, movesDirectory);
136
+ derivation.unjudgeable.push(fileTarget(target, 'the command carries more than one write'));
137
+ }
138
+ return;
139
+ }
140
+ const redirect = writes[0];
141
+ if (redirect === undefined)
142
+ return;
143
+ const target = resolveTarget(redirect.target.text, redirect.target.opaque, movesDirectory);
144
+ if (!('path' in target)) {
145
+ derivation.unjudgeable.push({ reason: target.reason });
146
+ return;
147
+ }
148
+ // Only stdout carries the content this layer can compute; `2>`/`&>` carry streams it cannot.
149
+ if (!STDOUT_WRITE_OPERATORS.has(redirect.operator)) {
150
+ derivation.unjudgeable.push({
151
+ path: target.path,
152
+ reason: `redirect ${redirect.operator} does not carry stdout`,
153
+ });
154
+ return;
155
+ }
156
+ const content = computeContent(command);
157
+ if ('reason' in content) {
158
+ derivation.unjudgeable.push({ path: target.path, reason: content.reason });
159
+ return;
160
+ }
161
+ derivation.evidence.push({
162
+ path: target.path,
163
+ content: content.content,
164
+ mode: redirect.operator.endsWith('>>') ? 'append' : 'truncate',
165
+ });
166
+ }
167
+ /**
168
+ * True when a word carries subshell group syntax. `(`/`)` are ordinary word characters
169
+ * to this tokenizer, so the grouping — and any `cd` inside it — is structurally
170
+ * invisible: a reinterpretation boundary this layer refuses to parse into.
171
+ */
172
+ function hasSubshellMarker(command) {
173
+ return command.words.some((word) => word.text.startsWith('(') || word.text.endsWith(')'));
174
+ }
175
+ /** Derive one simple command, top to bottom — the first matching case answers. */
176
+ function deriveCommand(command, movesDirectory, derivation) {
177
+ const first = command.words[0];
178
+ // A nested shell re-parses its arguments: a reinterpretation boundary, never parsed into.
179
+ // Read past any leading assignment, or `FOO=1 bash -c …` files nothing at all.
180
+ const name = commandNameWord(command);
181
+ if (name !== undefined && isNestedShellCommand(commandBasename(name))) {
182
+ derivation.unjudgeable.push({ reason: `nested shell execution: ${name.text}` });
183
+ return;
184
+ }
185
+ if (hasSubshellMarker(command)) {
186
+ const grouped = writeRedirects(command);
187
+ if (grouped.length === 0) {
188
+ derivation.unjudgeable.push({ reason: 'subshell group syntax is not parsed here' });
189
+ return;
190
+ }
191
+ for (const redirect of grouped) {
192
+ const target = resolveTarget(redirect.target.text, redirect.target.opaque, movesDirectory);
193
+ derivation.unjudgeable.push(fileTarget(target, 'subshell group syntax is not parsed here'));
194
+ }
195
+ return;
196
+ }
197
+ // A write redirect and a rule-detected write can ride ONE command (`sed -i f > log`,
198
+ // `… | tee f > /dev/null`) — each files its own row; an early return on either side
199
+ // would swallow the other.
200
+ const writes = writeRedirects(command);
201
+ deriveWrites(command, writes, movesDirectory, derivation);
202
+ const detected = DETECTION_RULES.flatMap((rule) => rule.detect(command));
203
+ for (const mutation of detected) {
204
+ const target = resolveTarget(mutation.path, false, movesDirectory);
205
+ derivation.unjudgeable.push(fileTarget(target, `${mutation.rule} writes content this layer does not compute`));
206
+ }
207
+ if (writes.length > 0 || detected.length > 0)
208
+ return;
209
+ // Nothing detected, but an opaque token under a command that is not proven read-only
210
+ // could still write — the signal remains even where the rules stay silent. Without that
211
+ // allowlist gate every read over a glob would file a line (the volume defence).
212
+ if (command.words.some((word) => word.opaque) && !isReadOnlyHead(command)) {
213
+ derivation.unjudgeable.push({
214
+ reason: `opaque token under ${first?.text ?? 'a command'} that is not proven read-only`,
215
+ });
216
+ }
217
+ }
218
+ /**
219
+ * Derive the file changes one shell command line proves.
220
+ *
221
+ * Never throws: a span the tokenizer could not read is exactly where a quiet pass would
222
+ * hide, so each one answers an unjudgeable entry carrying its reason — and the commands
223
+ * around it still contribute their real evidence.
224
+ */
225
+ export function deriveShellChanges(commandLine) {
226
+ const result = tokenizeCommandLine(commandLine);
227
+ // A directory change is line-scoped and order-blind: a write before it is as ambiguous
228
+ // as one after, since only execution decides which base each relative target resolves to.
229
+ const movesDirectory = result.commands.some((command) => {
230
+ // Read past leading assignments, like `deriveCommand` — `FOO=1 cd sub` moves the directory
231
+ // exactly as `cd sub` does. Missing it is not a silent skip: every relative target on
232
+ // the line would then be resolved against the repo root and filed as CONFIDENT evidence
233
+ // for a path the command never touched.
234
+ const name = commandNameWord(command);
235
+ return (name !== undefined && !name.opaque && DIRECTORY_CHANGE_COMMANDS.has(commandBasename(name)));
236
+ });
237
+ // A line with any unread span files at least one entry, whatever the commands around it
238
+ // derive: that row is the telemetry the shell axis contracts for, and a partial success
239
+ // that swallowed it would be a call passing unrecorded.
240
+ const derivation = {
241
+ evidence: [],
242
+ unjudgeable: result.unread.map((span) => ({ reason: span.reason })),
243
+ };
244
+ for (const command of result.commands)
245
+ deriveCommand(command, movesDirectory, derivation);
246
+ return derivation;
247
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `judgeShellModification` — the shell-mod meta-covenant's pure judge (zero I/O).
3
+ *
4
+ * Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
5
+ * values, never source literals) per simple command: the fixed detection rules catch writes
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
8
+ * mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
9
+ * tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
10
+ * run-all co-existence depends on that boundary.
11
+ */
12
+ import type { CovenantInput, CovenantVerdict } from '@polydeukes/core';
13
+ import { type SimpleCommand } from './bash-line.ts';
14
+ import type { CovenantRegistration, MetaCovenantRegistration } from './dispatch.ts';
15
+ /**
16
+ * `ShellModificationSpec` — the injected axes of the judge.
17
+ *
18
+ * `protectedPaths` are literal path strings; `shellToolNames` are the tool names whose
19
+ * calls carry shell lines; `commandArgNames` are the `args` keys those lines live under;
20
+ * `readOnlyCommands` are allowlist entries — space-separated word sequences (`'cat'`,
21
+ * `'git diff'`). Empty-string entries in every list are ignored (an unguarded `''` would
22
+ * match every path / tool / arg / command).
23
+ */
24
+ export type ShellModificationSpec = {
25
+ protectedPaths: string[];
26
+ shellToolNames: string[];
27
+ commandArgNames: string[];
28
+ readOnlyCommands: string[];
29
+ };
30
+ /**
31
+ * Commands proven read-only by shell semantics — the default allowlist. An entry is a
32
+ * leading word sequence; multi-word entries exist because a bare command name (`git`) can
33
+ * front mutating subcommands. Omission errs toward friction, never a hole.
34
+ *
35
+ * An entry must have no way to write a file through its own arguments, since the allowlist
36
+ * vouches for the command head while `matchesReadOnlyEntry` never inspects trailing argv.
37
+ * That is why `git diff`/`git log`/`git show` are absent: all accept `--output=<file>`, a
38
+ * redirect-free truncating write. `git status`/`git grep` reject `--output`, so they stay.
39
+ */
40
+ export declare const DEFAULT_READ_ONLY_COMMANDS: string[];
41
+ /**
42
+ * True when the command's leading words match the allowlist entry's word sequence. Exported
43
+ * so the transcript judge's allowlist clause absolves reads by this exact comparison instead
44
+ * of a fork that could drift from it.
45
+ */
46
+ export declare function matchesReadOnlyEntry(command: SimpleCommand, entry: string[]): boolean;
47
+ /**
48
+ * Judge a {@link CovenantInput} against the shell-mod spec (pure).
49
+ *
50
+ * For each `toolCalls[i]` whose `name` exactly equals a non-empty `shellToolNames` entry,
51
+ * every string value under a non-empty `commandArgNames` key is analyzed as a shell line;
52
+ * a shell call with zero such strings breaks (a misassembled arg name must not degrade
53
+ * into universal uphold). A span the tokenizer could not read breaks iff the dequoted span —
54
+ * or one of its shell-metacharacter fragments — mentions a protected path, and is answered
55
+ * before the commands so that a mention only the span can see is named as one. Non-shell
56
+ * calls, `subagentSpawns`, and `userMessages` are never judged.
57
+ */
58
+ export declare function judgeShellModification(input: CovenantInput, spec: ShellModificationSpec): CovenantVerdict;
59
+ /**
60
+ * `ShellModRegistrationSpec` — the assembly values baked into the registration. The call
61
+ * 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.
64
+ */
65
+ export type ShellModRegistrationSpec = {
66
+ protectedPaths: string[];
67
+ shellTools: string[];
68
+ /**
69
+ * The `args` keys shell lines live under. ABSENT is the empty surface, which the thunk's
70
+ * entry gate refuses — naming no command arg would otherwise uphold every call.
71
+ */
72
+ commandArgs?: string[];
73
+ readOnlyCommands?: string[];
74
+ witness?: CovenantRegistration['witness'];
75
+ };
76
+ /**
77
+ * Build the shell-mod registration. Routing stays path mention; the judgment is the thunk.
78
+ *
79
+ * The misassembly gate lives at the thunk's entry: zero valid entries in any of the three
80
+ * required lists would make {@link judgeShellModification} uphold every call, so it answers
81
+ * the unjudgeable outcome instead, which no enforce level softens. The allowlist is exempt
82
+ * — empty just means stricter.
83
+ */
84
+ export declare function shellModRegistration(spec: ShellModRegistrationSpec): MetaCovenantRegistration;