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,209 @@
1
+ /**
2
+ * `mentionsPath` — the single path-mention semantic shared by the dispatcher and the
3
+ * pure judges.
4
+ *
5
+ * Both the path-routing dispatcher (`matchRegistrations`) and any covenant judge that keys
6
+ * on a protected path import this one function, so the two layers can never drift apart.
7
+ * Argument names are never inspected — only string *values* are scanned, at any depth,
8
+ * keeping the traversal agent-neutral.
9
+ *
10
+ * Interior `.`/`..` resolution is a SECOND comparison, never a replacement: raw segments
11
+ * match first, dot-resolved segments only if that fails. Because the result is a union, a
12
+ * newly closed notation can only ever add matches — a replacement pass would silently
13
+ * withdraw defences that the raw comparison already had.
14
+ *
15
+ * A glob, a variable expansion, and a tilde are deliberately NOT read. None can be resolved
16
+ * without running the shell or touching the filesystem, and a judge that guesses at them
17
+ * either misses the real target or blocks an innocent one. They stay undecidable here and
18
+ * are answered where undecidability belongs: the Bash axis's opaque-token rule and the skip
19
+ * registrations. A spelling some layer genuinely *can* resolve — the home directory in front
20
+ * of the session transcript — is closed by the layer that knows the value, via a dedicated
21
+ * `matches` predicate rather than a protected path, so no home spelling is inferred here.
22
+ */
23
+ /**
24
+ * Normalize a path into segments: strip leading `./`, trailing `/`, split on `/`, drop
25
+ * empties. Exported so the self-mod judge can tell a judgeable evidence path from a
26
+ * degenerate one (`''`, `'.'`, `'/'` — zero segments) that proves nothing.
27
+ *
28
+ * A lone `.` survives as a segment, and resolving interior dots is a separate pass inside
29
+ * {@link pathMatchesProtected}. Folding that pass in here would change what the
30
+ * degenerate-evidence check built on top of this function counts as degenerate.
31
+ */
32
+ export function pathSegments(path) {
33
+ return path
34
+ .replace(/^(\.\/)+/, '')
35
+ .replace(/\/+$/, '')
36
+ .split('/')
37
+ .filter((segment) => segment !== '');
38
+ }
39
+ /**
40
+ * Return the mutation target proven by a call's `fileChange` evidence, or `null` when the
41
+ * evidence proves none.
42
+ *
43
+ * @param call - The tool call whose evidence is inspected
44
+ * @returns The change path when the evidence carries a recognized kind and a path with at
45
+ * least one non-`.` segment; otherwise `null`
46
+ */
47
+ export function provenChangePath(call) {
48
+ const evidence = call.fileChange;
49
+ if (typeof evidence !== 'object' || evidence === null)
50
+ return null;
51
+ const { kind, path } = evidence;
52
+ if (typeof path !== 'string')
53
+ return null;
54
+ // Core `parseInput` validates the collection shapes, not the element ones, so evidence
55
+ // is usable only when it could prove a target: a recognized discriminant and a path that
56
+ // carries segments to judge. A one-field stub, a bogus kind, or a degenerate path (`''`,
57
+ // `'.'`, `'/'` — zero segments) proves nothing and must fall through rather than be
58
+ // dereferenced or, worse, suppress the fallback — the evidence branch upholding on proof
59
+ // it never had is a fail-open, and an exported pure judge that throws is a bypass vector.
60
+ // `pathSegments` keeps a lone `.` as a segment, so require one that names a file.
61
+ if (!pathSegments(path).some((segment) => segment !== '.'))
62
+ return null;
63
+ return kind === 'create' || kind === 'modify' || kind === 'delete' ? path : null;
64
+ }
65
+ /** True iff `needle` occurs as a contiguous segment run inside `haystack` (any offset). */
66
+ function containsSegmentRun(haystack, needle) {
67
+ if (needle.length === 0 || needle.length > haystack.length)
68
+ return false;
69
+ for (let start = 0; start + needle.length <= haystack.length; start++) {
70
+ if (needle.every((segment, i) => segment === haystack[start + i]))
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ /**
76
+ * One comparison, shared by both passes. The two directions are deliberately asymmetric:
77
+ * - descendant / equal: the protected segments appear as a contiguous run at ANY offset in
78
+ * the candidate, so an ABSOLUTE `file_path` (`/home/u/proj/core/src/x` — the real Edit
79
+ * payload shape) matches the relative protected `core/src`;
80
+ * - ancestor: the WHOLE candidate is a root-anchored prefix of the protected path, so the
81
+ * relative parent op `rm -rf packages/core` matches but an unrelated `vendor/packages`
82
+ * whose tail merely coincides with the protected head does NOT.
83
+ * The asymmetry is load-bearing: allowing any candidate *suffix* to head the protected path
84
+ * would block legitimate unrelated dirs (`x/packages/core`). The cost is that an ABSOLUTE
85
+ * ancestor path (`rm -rf /abs/.../packages/core`) is not caught — an accepted non-goal
86
+ * (complete Bash lockdown was never the goal; the relative form is still caught, and the
87
+ * over-block alternative is worse). The segment boundary is exact, so `core/src-generated`
88
+ * never matches `core/src`.
89
+ */
90
+ function segmentsMatch(a, b) {
91
+ if (a.length === 0)
92
+ return false;
93
+ if (containsSegmentRun(a, b))
94
+ return true;
95
+ // Ancestor: the candidate is a proper root-anchored prefix of the protected path.
96
+ return a.length < b.length && a.every((segment, i) => segment === b[i]);
97
+ }
98
+ /**
99
+ * Resolve `.` and `..` against the preceding segment — pure string work, no filesystem and
100
+ * no working directory, so the answer is the same wherever the judge runs.
101
+ *
102
+ * A `..` with nothing left to cancel is KEPT rather than dropped. Dropping it would collapse
103
+ * `../packages` into `packages` and hand a sibling checkout the protection meant for this
104
+ * one; keeping it leaves a segment that matches nothing, which is the honest answer for a
105
+ * path that points outside the tree.
106
+ *
107
+ * Exported so a judge whose own equality needs the same second pass (the transcript
108
+ * predicate) shares this one implementation instead of forking it per site.
109
+ */
110
+ export function resolveDotSegments(segments) {
111
+ const resolved = [];
112
+ for (const segment of segments) {
113
+ if (segment === '.')
114
+ continue;
115
+ if (segment === '..' && resolved.length > 0 && resolved[resolved.length - 1] !== '..') {
116
+ resolved.pop();
117
+ continue;
118
+ }
119
+ resolved.push(segment);
120
+ }
121
+ return resolved;
122
+ }
123
+ /**
124
+ * True iff `candidate` names the protected path, a descendant of it, or a (relative) ancestor
125
+ * of it — compared on path segments, not raw substrings, by {@link segmentsMatch}.
126
+ *
127
+ * Two passes, unioned. The raw pass is the shipped semantic and runs first: a command that
128
+ * spells the protected path out loud is caught by it no matter what the path resolves to
129
+ * afterwards, which is why `rm -rf .claude/hooks/../..` breaks here rather than needing a
130
+ * rule of its own. The dot-resolved pass runs only when the raw one finds nothing, and is
131
+ * what `packages/core/./dist/index.js` and `packages/core/src/../dist/index.js` need. Because
132
+ * it is a union it can only ever add matches, never withdraw one.
133
+ */
134
+ export function pathMatchesProtected(candidate, protectedPath) {
135
+ const a = pathSegments(candidate);
136
+ const b = pathSegments(protectedPath);
137
+ if (b.length === 0)
138
+ return false;
139
+ if (segmentsMatch(a, b))
140
+ return true;
141
+ return segmentsMatch(resolveDotSegments(a), b);
142
+ }
143
+ /**
144
+ * Extract path candidates from one string token. The token is split on shell separators
145
+ * that join a path to other lexemes — whitespace, `=`, `,`, parentheses, backtick — so a path
146
+ * embedded in a compound token (a `--flag=path`, an opaque command substitution, an eval's
147
+ * quoted argument) surfaces as its own candidate while a standalone token stays intact (so the
148
+ * segment-boundary trap still rejects a sibling like `core/src-generated`). `/` is never a
149
+ * separator (it is the path's own segment boundary); `:` is deliberately NOT a separator
150
+ * either — splitting on it shatters URLs (`https://…`) into fragments that the offset-free
151
+ * descendant match then over-blocks, and a colon-joined path list is already reached by the
152
+ * contiguous-run match without the split.
153
+ */
154
+ export function pathCandidates(token) {
155
+ return token.split(/[\s=,()`]+/).filter((fragment) => fragment !== '');
156
+ }
157
+ /**
158
+ * Extract path candidates from a whole command line the tokenizer REFUSED — the fallback-only
159
+ * counterpart of {@link pathCandidates}.
160
+ *
161
+ * The precondition is the opposite one. On the tokenized path an operator between two words
162
+ * has already become a word boundary, so `pathCandidates`' separator set never needed the
163
+ * operators themselves. A fallback branch has no tokenizer left and gets the raw line, where
164
+ * nothing consumed them and a path glued to one (`packages/core/dist;echo x`) stayed a single
165
+ * unmatchable segment — so the set here is wider by exactly what the tokenizer would have
166
+ * eaten: `;` `&` `|` `<` `>`.
167
+ *
168
+ * The line itself stays a candidate alongside the fragments, so a protected path whose own
169
+ * segment carries an operator (`pkg/a&b/dist`) is still matchable; an added form can only add
170
+ * a match, never withdraw one. `:` stays out for the reason {@link pathCandidates} records.
171
+ *
172
+ * Widening a fragment boundary widens the ancestor direction with it: `…?x=1&packages=1`
173
+ * splits to a bare `packages`, which `segmentsMatch` accepts as a root-anchored ancestor of a
174
+ * protected `packages/core/dist`. That over-block is accepted rather than narrowed — the
175
+ * narrowing that would spare it also drops a glued ancestor destroy
176
+ * (`rm -rf packages/core;echo x`), which is the defence this fallback exists to provide.
177
+ */
178
+ export function untokenizableLineCandidates(line) {
179
+ const fragments = line.split(/[;&|<>]+/).filter((f) => f !== '' && f !== line);
180
+ return [line, ...fragments];
181
+ }
182
+ /**
183
+ * True when `predicate` holds for any string value inside `value`, at any depth.
184
+ *
185
+ * Arrays and plain objects are walked by value; keys are never scanned, and non-string
186
+ * primitives never match. Short-circuits on the first hit — the walk answers an existence
187
+ * question, so a caller that needs every match wants its own traversal.
188
+ */
189
+ export function someStringValue(value, predicate) {
190
+ if (typeof value === 'string') {
191
+ return predicate(value);
192
+ }
193
+ if (Array.isArray(value)) {
194
+ return value.some((item) => someStringValue(item, predicate));
195
+ }
196
+ if (typeof value === 'object' && value !== null) {
197
+ return Object.values(value).some((item) => someStringValue(item, predicate));
198
+ }
199
+ return false;
200
+ }
201
+ /**
202
+ * Recursively test whether any string value inside `value` matches `path` by path-segment
203
+ * containment (ancestor / descendant / equal). Each string is split into path candidates,
204
+ * each tested via {@link pathMatchesProtected}. Only string values are scanned; keys,
205
+ * numbers, and other primitives never match.
206
+ */
207
+ export function mentionsPath(value, path) {
208
+ return someStringValue(value, (text) => pathCandidates(text).some((candidate) => pathMatchesProtected(candidate, path)));
209
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The judge as the composition roots see it: the seven verbs they call, gathered in one
3
+ * object. A test replaces one member of it; the roots default to the real seven.
4
+ *
5
+ * A leaf module on purpose — it imports the judge and nothing else, so the session subpath
6
+ * that loads it does not also load the change-set surface's translator and reader.
7
+ */
8
+ import { compileDisciplineRegistrations } from './discipline.ts';
9
+ import { dispatchCovenants } from './dispatch.ts';
10
+ import { selfModRegistration } from './self-mod.ts';
11
+ import { shellModRegistration } from './shell-mod.ts';
12
+ import { planSources, supplySources } from './supply.ts';
13
+ import { transcriptModRegistration } from './transcript-mod.ts';
14
+ /** The judge verbs the composition roots call — the seam a test replaces one member of. */
15
+ export type CovenantModule = {
16
+ dispatchCovenants: typeof dispatchCovenants;
17
+ compileDisciplineRegistrations: typeof compileDisciplineRegistrations;
18
+ selfModRegistration: typeof selfModRegistration;
19
+ shellModRegistration: typeof shellModRegistration;
20
+ transcriptModRegistration: typeof transcriptModRegistration;
21
+ planSources: typeof planSources;
22
+ supplySources: typeof supplySources;
23
+ };
24
+ /** The real seven — what judges a call unless a test injects a replacement. */
25
+ export declare const covenantModule: CovenantModule;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The judge as the composition roots see it: the seven verbs they call, gathered in one
3
+ * object. A test replaces one member of it; the roots default to the real seven.
4
+ *
5
+ * A leaf module on purpose — it imports the judge and nothing else, so the session subpath
6
+ * that loads it does not also load the change-set surface's translator and reader.
7
+ */
8
+ import { compileDisciplineRegistrations } from './discipline.js';
9
+ import { dispatchCovenants } from './dispatch.js';
10
+ import { selfModRegistration } from './self-mod.js';
11
+ import { shellModRegistration } from './shell-mod.js';
12
+ import { planSources, supplySources } from './supply.js';
13
+ import { transcriptModRegistration } from './transcript-mod.js';
14
+ /** The real seven — what judges a call unless a test injects a replacement. */
15
+ export const covenantModule = {
16
+ dispatchCovenants,
17
+ compileDisciplineRegistrations,
18
+ selfModRegistration,
19
+ shellModRegistration,
20
+ transcriptModRegistration,
21
+ planSources,
22
+ supplySources,
23
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Detection rules for the mutation-rule seam.
3
+ *
4
+ * Pure functions only — each rule is `SimpleCommand → MutationTarget[]`, nothing more.
5
+ * Every rule stays silent on opaque tokens: an unknowable value is never reported as a
6
+ * confident path. The tokenizer already marks such commands indeterminate, so the
7
+ * fail-closed signal survives the silence. Protected-path matching, blocking, allowlists,
8
+ * and telemetry belong to the judges that consume these rules.
9
+ */
10
+ import type { MutationRule, WordToken } from './bash-line.ts';
11
+ /** The final path segment of a command word (`/usr/bin/tee` → `tee`). */
12
+ export declare function commandBasename(word: WordToken): string;
13
+ /**
14
+ * True when a `>&`-family target is an fd reference: all digits, `-` (close), or the
15
+ * digits+`-` move-fd form (`2>&1-` moves fd 1, touching no file). Exported as the SSOT
16
+ * of that boundary — the shell-evidence derivation consults the same judgment.
17
+ */
18
+ export declare function isFdReference(text: string): boolean;
19
+ /**
20
+ * Reports the target path of every write-direction redirect (any operator containing `>`).
21
+ * Read redirects (`<`) and fd duplication (`2>&1`, `>&-`) are excluded; a csh-style
22
+ * `>& file` whose target is not an fd reference is still a write.
23
+ */
24
+ export declare const redirectWriteRule: MutationRule;
25
+ /**
26
+ * Reports every non-flag argument of a `tee` command (first-word basename match, so
27
+ * `/usr/bin/tee` fires too). Flags are skipped until the `--` end-of-options marker;
28
+ * after it, `-`-prefixed words are paths. Wrapper commands (`sudo tee`) never fire —
29
+ * the judges' path-mention backstop covers them.
30
+ */
31
+ export declare const teeRule: MutationRule;
32
+ /**
33
+ * Reports the file operands of an in-place `sed` (first-word basename match, so
34
+ * `/usr/bin/sed` fires too). Without an in-place flag sed writes to stdout — silence.
35
+ * The `-e`/`-f` family is skipped with its value (the `-f` script file is read, not
36
+ * written); when no such flag is present the first positional operand is the sed script
37
+ * — skipped even after `--`. Wrapper commands (`sudo sed`) never fire — the judges'
38
+ * path-mention backstop covers them.
39
+ */
40
+ export declare const sedInPlaceRule: MutationRule;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Detection rules for the mutation-rule seam.
3
+ *
4
+ * Pure functions only — each rule is `SimpleCommand → MutationTarget[]`, nothing more.
5
+ * Every rule stays silent on opaque tokens: an unknowable value is never reported as a
6
+ * confident path. The tokenizer already marks such commands indeterminate, so the
7
+ * fail-closed signal survives the silence. Protected-path matching, blocking, allowlists,
8
+ * and telemetry belong to the judges that consume these rules.
9
+ */
10
+ const REDIRECT_WRITE_RULE_NAME = 'redirect-write';
11
+ const TEE_RULE_NAME = 'tee';
12
+ const SED_IN_PLACE_RULE_NAME = 'sed-in-place';
13
+ /** The final path segment of a command word (`/usr/bin/tee` → `tee`). */
14
+ export function commandBasename(word) {
15
+ return word.text.slice(word.text.lastIndexOf('/') + 1);
16
+ }
17
+ /**
18
+ * True when a `>&`-family target is an fd reference: all digits, `-` (close), or the
19
+ * digits+`-` move-fd form (`2>&1-` moves fd 1, touching no file). Exported as the SSOT
20
+ * of that boundary — the shell-evidence derivation consults the same judgment.
21
+ */
22
+ export function isFdReference(text) {
23
+ return text === '-' || /^[0-9]+-?$/.test(text);
24
+ }
25
+ /**
26
+ * Reports the target path of every write-direction redirect (any operator containing `>`).
27
+ * Read redirects (`<`) and fd duplication (`2>&1`, `>&-`) are excluded; a csh-style
28
+ * `>& file` whose target is not an fd reference is still a write.
29
+ */
30
+ export const redirectWriteRule = {
31
+ name: REDIRECT_WRITE_RULE_NAME,
32
+ detect(command) {
33
+ const targets = [];
34
+ for (const redirect of command.redirects) {
35
+ if (!redirect.operator.includes('>'))
36
+ continue;
37
+ if (redirect.operator.endsWith('>&') && isFdReference(redirect.target.text))
38
+ continue;
39
+ if (redirect.target.opaque)
40
+ continue;
41
+ targets.push({ path: redirect.target.text, rule: REDIRECT_WRITE_RULE_NAME });
42
+ }
43
+ return targets;
44
+ },
45
+ };
46
+ /**
47
+ * Reports every non-flag argument of a `tee` command (first-word basename match, so
48
+ * `/usr/bin/tee` fires too). Flags are skipped until the `--` end-of-options marker;
49
+ * after it, `-`-prefixed words are paths. Wrapper commands (`sudo tee`) never fire —
50
+ * the judges' path-mention backstop covers them.
51
+ */
52
+ export const teeRule = {
53
+ name: TEE_RULE_NAME,
54
+ detect(command) {
55
+ const first = command.words[0];
56
+ if (first === undefined || first.opaque)
57
+ return [];
58
+ if (commandBasename(first) !== 'tee')
59
+ return [];
60
+ const targets = [];
61
+ let optionsEnded = false;
62
+ for (const word of command.words.slice(1)) {
63
+ if (!optionsEnded) {
64
+ if (word.text === '--') {
65
+ optionsEnded = true;
66
+ continue;
67
+ }
68
+ // A lone `-` is a file operand, not a flag — GNU tee writes a literal `-` file.
69
+ if (word.text.startsWith('-') && word.text !== '-')
70
+ continue;
71
+ }
72
+ if (word.opaque)
73
+ continue;
74
+ targets.push({ path: word.text, rule: TEE_RULE_NAME });
75
+ }
76
+ return targets;
77
+ },
78
+ };
79
+ /** True for any in-place flag form: `-i`, `-i<suffix>`, `--in-place`, `--in-place=<suffix>`. */
80
+ function isInPlaceFlag(text) {
81
+ return text.startsWith('-i') || text === '--in-place' || text.startsWith('--in-place=');
82
+ }
83
+ // The script-flag forms whose value arrives in the NEXT word. isScriptFlag must cover
84
+ // every flag listed here, or `scriptSkipped` mis-seeds and the wrong operand is dropped.
85
+ const SEPARATED_SCRIPT_FLAGS = ['-e', '-f', '--expression', '--file'];
86
+ /** True for any script-supplying flag form (`-e`/`-f` family) — its value is never a file. */
87
+ function isScriptFlag(text) {
88
+ return (SEPARATED_SCRIPT_FLAGS.includes(text) ||
89
+ text.startsWith('-e') ||
90
+ text.startsWith('-f') ||
91
+ text.startsWith('--expression=') ||
92
+ text.startsWith('--file='));
93
+ }
94
+ /**
95
+ * Reports the file operands of an in-place `sed` (first-word basename match, so
96
+ * `/usr/bin/sed` fires too). Without an in-place flag sed writes to stdout — silence.
97
+ * The `-e`/`-f` family is skipped with its value (the `-f` script file is read, not
98
+ * written); when no such flag is present the first positional operand is the sed script
99
+ * — skipped even after `--`. Wrapper commands (`sudo sed`) never fire — the judges'
100
+ * path-mention backstop covers them.
101
+ */
102
+ export const sedInPlaceRule = {
103
+ name: SED_IN_PLACE_RULE_NAME,
104
+ detect(command) {
105
+ const first = command.words[0];
106
+ if (first === undefined || first.opaque)
107
+ return [];
108
+ if (commandBasename(first) !== 'sed')
109
+ return [];
110
+ const args = command.words.slice(1);
111
+ if (!args.some((word) => isInPlaceFlag(word.text)))
112
+ return [];
113
+ const targets = [];
114
+ let optionsEnded = false;
115
+ let skipNext = false;
116
+ // With no script flag anywhere, the first positional operand is the script itself.
117
+ let scriptSkipped = args.some((word) => isScriptFlag(word.text));
118
+ for (const word of args) {
119
+ if (skipNext) {
120
+ skipNext = false;
121
+ continue;
122
+ }
123
+ if (!optionsEnded) {
124
+ if (word.text === '--') {
125
+ optionsEnded = true;
126
+ continue;
127
+ }
128
+ // A lone `-` is an operand, not a flag — same boundary as the tee rule.
129
+ if (word.text.startsWith('-') && word.text !== '-') {
130
+ // The separated flag forms carry their value in the NEXT word — skip it too.
131
+ if (SEPARATED_SCRIPT_FLAGS.includes(word.text))
132
+ skipNext = true;
133
+ continue;
134
+ }
135
+ }
136
+ if (!scriptSkipped) {
137
+ scriptSkipped = true;
138
+ continue;
139
+ }
140
+ if (word.opaque)
141
+ continue;
142
+ targets.push({ path: word.text, rule: SED_IN_PLACE_RULE_NAME });
143
+ }
144
+ return targets;
145
+ },
146
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The seven relations, each answering a witness list rather than a boolean: an empty list
3
+ * means the relation holds, and every other list names the elements that broke it, in the
4
+ * order the extraction supplied them. No relation sorts or deduplicates, because the two
5
+ * surfaces that read a judgment agree on the witness order only if it is the input's.
6
+ *
7
+ * `empty`, `subset` and `ordered` are written directly; the other four are expansions over
8
+ * them — `nonEmpty` is the negation of `empty`, `equal` is `subset` in both directions,
9
+ * `implies` is `subset` of the two key projections, and `unchanged` is `equal` over the keys
10
+ * the two states share.
11
+ */
12
+ import { type Items } from './extract-steps.ts';
13
+ /** One element for which a relation does not hold; `side` is `equal`'s two directions. */
14
+ export type Witness = {
15
+ readonly key: string;
16
+ readonly value: unknown;
17
+ readonly side?: 'left' | 'right';
18
+ };
19
+ /** The pre value behind an `unchanged` witness, for the message that reports the change. */
20
+ export type WitnessWithBefore = Witness & {
21
+ readonly before?: unknown;
22
+ };
23
+ /** Every item is a witness: the relation asked for none. */
24
+ export declare function relateEmpty(of: Items): readonly Witness[];
25
+ /**
26
+ * The negation of `empty`. Emptiness itself is the witness, so the one witness names the
27
+ * extraction rather than an element — there is no element to name.
28
+ */
29
+ export declare function relateNonEmpty(of: Items, extractName: string): readonly Witness[];
30
+ /** The `of` items whose value occurs nowhere in `in`, in `of` order, duplicates kept. */
31
+ export declare function relateSubset(of: Items, inItems: Items): readonly Witness[];
32
+ /** `subset` both ways: the left-only items first, then the right-only ones. */
33
+ export declare function relateEqual(left: Items, right: Items): readonly Witness[];
34
+ /** `subset` of the key projections: the `of` items whose key `requires` does not carry. */
35
+ export declare function relateImplies(of: Items, requires: Items): readonly Witness[];
36
+ /**
37
+ * Adjacent pairs rise; `strict` forbids equal neighbours. The witness is the later item of
38
+ * each pair that breaks it, so a caller reading the first witness sees where the sequence
39
+ * turned — an ordering reduced to a sort would answer only whether it held.
40
+ */
41
+ export declare function relateOrdered(of: Items, strict: boolean): readonly Witness[];
42
+ /**
43
+ * `equal` over the shared keys: the post item of every key both states carry whose value
44
+ * changed. A key only one side has is an addition or a removal, not a change.
45
+ */
46
+ export declare function relateUnchanged(pre: Items, post: Items): readonly WitnessWithBefore[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The seven relations, each answering a witness list rather than a boolean: an empty list
3
+ * means the relation holds, and every other list names the elements that broke it, in the
4
+ * order the extraction supplied them. No relation sorts or deduplicates, because the two
5
+ * surfaces that read a judgment agree on the witness order only if it is the input's.
6
+ *
7
+ * `empty`, `subset` and `ordered` are written directly; the other four are expansions over
8
+ * them — `nonEmpty` is the negation of `empty`, `equal` is `subset` in both directions,
9
+ * `implies` is `subset` of the two key projections, and `unchanged` is `equal` over the keys
10
+ * the two states share.
11
+ */
12
+ import { canonical, comparatorFor, sameValue } from './extract-steps.js';
13
+ /** Every item is a witness: the relation asked for none. */
14
+ export function relateEmpty(of) {
15
+ return of.map(({ key, value }) => ({ key, value }));
16
+ }
17
+ /**
18
+ * The negation of `empty`. Emptiness itself is the witness, so the one witness names the
19
+ * extraction rather than an element — there is no element to name.
20
+ */
21
+ export function relateNonEmpty(of, extractName) {
22
+ return of.length === 0 ? [{ key: extractName, value: null }] : [];
23
+ }
24
+ /** The `of` items whose value occurs nowhere in `in`, in `of` order, duplicates kept. */
25
+ export function relateSubset(of, inItems) {
26
+ const present = new Set(inItems.map((item) => canonical(item.value)));
27
+ return of
28
+ .filter((item) => !present.has(canonical(item.value)))
29
+ .map(({ key, value }) => ({ key, value }));
30
+ }
31
+ /** `subset` both ways: the left-only items first, then the right-only ones. */
32
+ export function relateEqual(left, right) {
33
+ return [
34
+ ...relateSubset(left, right).map((witness) => ({ ...witness, side: 'left' })),
35
+ ...relateSubset(right, left).map((witness) => ({ ...witness, side: 'right' })),
36
+ ];
37
+ }
38
+ /** `subset` of the key projections: the `of` items whose key `requires` does not carry. */
39
+ export function relateImplies(of, requires) {
40
+ const keys = new Set(requires.map((item) => item.key));
41
+ return of.filter((item) => !keys.has(item.key)).map(({ key, value }) => ({ key, value }));
42
+ }
43
+ /**
44
+ * Adjacent pairs rise; `strict` forbids equal neighbours. The witness is the later item of
45
+ * each pair that breaks it, so a caller reading the first witness sees where the sequence
46
+ * turned — an ordering reduced to a sort would answer only whether it held.
47
+ */
48
+ export function relateOrdered(of, strict) {
49
+ const witnesses = [];
50
+ const compare = comparatorFor(of);
51
+ for (let index = 1; index < of.length; index += 1) {
52
+ const order = compare(of[index - 1], of[index]);
53
+ if (order > 0 || (strict && order === 0)) {
54
+ witnesses.push({ key: of[index].key, value: of[index].value });
55
+ }
56
+ }
57
+ return witnesses;
58
+ }
59
+ /**
60
+ * `equal` over the shared keys: the post item of every key both states carry whose value
61
+ * changed. A key only one side has is an addition or a removal, not a change.
62
+ */
63
+ export function relateUnchanged(pre, post) {
64
+ const before = new Map(pre.map((item) => [item.key, item.value]));
65
+ return post
66
+ .filter((item) => before.has(item.key) && !sameValue(before.get(item.key), item.value))
67
+ .map((item) => ({ key: item.key, value: item.value, before: before.get(item.key) }));
68
+ }
@@ -0,0 +1,95 @@
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 { type CovenantVerdict, type EnforceLevel, EXIT_BREAK_BLOCKING, EXIT_UPHOLD, type SkipReason, type TelemetryEvent } from '@polydeukes/core';
11
+ import type { Break } from './declaration-engine.ts';
12
+ /** The wrapper's final verdict — `1` never escapes: a break becomes the blocking `2`. */
13
+ type WrapperExitCode = typeof EXIT_UPHOLD | typeof EXIT_BREAK_BLOCKING;
14
+ /**
15
+ * What a judge thunk answers: `0` uphold, `1` break, `2` unjudgeable, and `reason` naming
16
+ * the break for the agent that has to read it. `witnesses` carries the elements a
17
+ * declaration's break was found on; the body answers with values and the wrapper turns
18
+ * them into the row's fifth field.
19
+ *
20
+ * `skipped` is the body's own report that it evaluated no relation — exit 0 without a
21
+ * judgment. It is recorded as a `skipped` row carrying that token rather than as `passed`,
22
+ * which would read as a covenant upheld.
23
+ */
24
+ export type JudgeOutcome = {
25
+ exitCode: number;
26
+ reason?: string;
27
+ witnesses?: readonly Break[];
28
+ skipped?: SkipReason;
29
+ };
30
+ /**
31
+ * `runCovenant` specification.
32
+ *
33
+ * `body` is an in-process judge thunk with the payload and its options already bound by
34
+ * the assembly; it is the only judgment this wrapper performs. `subject` defaults to the
35
+ * `-` sentinel in telemetry when absent. `telemetryPath` is always an explicit argument.
36
+ * `enforce` selects the translation column: absent defaults to `block`. `witness` is the
37
+ * valve axis — a zero-arg thunk whose arguments the caller has already bound, consulted
38
+ * only once the body has run and its outcome translated to `blocked`.
39
+ */
40
+ export type RunCovenantSpec = {
41
+ body: () => Promise<JudgeOutcome>;
42
+ label: string;
43
+ subject?: string;
44
+ telemetryPath: string;
45
+ enforce?: EnforceLevel;
46
+ witness?: () => boolean;
47
+ };
48
+ /**
49
+ * Translate a body outcome into the wrapper verdict and telemetry event (pure).
50
+ *
51
+ * `bodyExitCode === 0` (uphold) passes; every other outcome — a break report (`1`), the
52
+ * body's own fail-closed (`2`), any uninterpretable code (`3+`), or a body that answered
53
+ * nothing interpretable (`null`) — is fail-closed to the blocking `2` / `blocked`. The
54
+ * unconditional 1→2 translation lives here, isolated, so it has one place to evolve in.
55
+ *
56
+ * `enforce` relaxes ONLY the verdict cell: under `advise` a break report (`1`) becomes
57
+ * `0` / `advised` — recorded, not blocking. Every unjudgeable outcome (`2`, `3+`, `null`)
58
+ * stays `2` / `blocked` regardless of level.
59
+ */
60
+ export declare function translateExitCode(bodyExitCode: number | null, enforce?: EnforceLevel): {
61
+ exitCode: WrapperExitCode;
62
+ event: TelemetryEvent;
63
+ };
64
+ /** Turn a pure judge's verdict into the outcome a thunk answers. */
65
+ export declare function outcomeFromVerdict(verdict: CovenantVerdict): JudgeOutcome;
66
+ /** The unjudgeable outcome: a misassembly or an input no judge could read (`2`, no reason). */
67
+ export declare const UNJUDGEABLE_OUTCOME: JudgeOutcome;
68
+ /** What one wrapped judgment answers — the final exit code and the telemetry event recorded. */
69
+ export type RunCovenantVerdict = {
70
+ exitCode: typeof EXIT_UPHOLD | typeof EXIT_BREAK_BLOCKING;
71
+ event: TelemetryEvent;
72
+ };
73
+ /**
74
+ * Run a covenant body through the wrapper.
75
+ *
76
+ * The order is judge → translate → valve: the body always runs, and only a `blocked`
77
+ * translation has anything for the valve to relax into `0` / `witnessed`. Whatever that
78
+ * leaves is recorded ONCE — one call, one row — so a witnessed break never leaves a
79
+ * `blocked` row beside its `witnessed` one.
80
+ *
81
+ * The break reason goes to stderr whenever the thunk carried one, whatever the level and
82
+ * whatever the final event: gating it on the verdict would leave `advised` mute and the
83
+ * valve silent about what it opened.
84
+ *
85
+ * A body that answers `skipped` evaluated no relation, so it never reaches the translation
86
+ * table or the valve: the row says so and the call upholds.
87
+ *
88
+ * Resolves with the wrapper's final `exitCode` (`0` or `2`) and the telemetry `event` that
89
+ * was recorded. The event is surfaced rather than left to callers: the valve is impure, so
90
+ * recomputing the event would consult it a second time. Logging is fail-open
91
+ * via {@link appendRecordFailOpen}: a telemetry failure never alters the verdict and never
92
+ * throws. The gate closes; the measurement stays open.
93
+ */
94
+ export declare function runCovenant(spec: RunCovenantSpec): Promise<RunCovenantVerdict>;
95
+ export {};