@orkestrel/scaffold 0.0.108 → 0.0.109

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 (157) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
  7. package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
  8. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
  9. package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
  10. package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
  11. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
  12. package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
  13. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
  14. package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
  15. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
  16. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
  17. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
  18. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
  19. package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
  20. package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
  21. package/dist/agents/templates/brief.md +1 -1
  22. package/dist/bin/main.js +51 -46
  23. package/dist/bin/main.js.map +1 -1
  24. package/dist/host/AGENTS.md +23 -21
  25. package/dist/host/agents/orchestration.md +43 -18
  26. package/dist/host/agents/platforms/windows.md +3 -3
  27. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  28. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  29. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  30. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  31. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  32. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  33. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  34. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  35. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  36. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
  37. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
  38. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
  39. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
  48. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  49. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  50. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  51. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  52. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  53. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  54. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  55. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  56. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  58. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  59. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  60. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  63. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  64. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  65. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  66. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  67. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  68. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
  69. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
  70. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
  71. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
  72. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  73. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  74. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  75. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  76. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  77. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  78. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  79. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
  80. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  81. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  82. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  83. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  86. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  87. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  88. package/dist/host/agents/templates/brief.md +1 -1
  89. package/dist/host/agents/transports/claude-cli.md +3 -3
  90. package/dist/host/agents/transports/codex.md +2 -7
  91. package/dist/host/agents/transports/cursor.md +2 -3
  92. package/dist/host/claude/AGENTS.md +2 -2
  93. package/dist/host/claude/agents/analyst.md +3 -3
  94. package/dist/host/claude/agents/astra.md +3 -3
  95. package/dist/host/claude/agents/builder.md +1 -1
  96. package/dist/host/claude/agents/checker.md +1 -1
  97. package/dist/host/claude/agents/distiller.md +1 -1
  98. package/dist/host/claude/agents/grok.md +1 -1
  99. package/dist/host/claude/agents/opus.md +2 -2
  100. package/dist/host/claude/agents/orkestrel.md +57 -90
  101. package/dist/host/claude/agents/researcher.md +1 -1
  102. package/dist/host/claude/agents/reviewer.md +4 -0
  103. package/dist/host/claude/agents/scout.md +1 -1
  104. package/dist/host/claude/agents/verifier.md +1 -1
  105. package/dist/host/claude/rules/application.md +14 -21
  106. package/dist/host/claude/rules/architecture.md +34 -44
  107. package/dist/host/claude/rules/browser.md +3 -4
  108. package/dist/host/claude/rules/campaign.md +2 -2
  109. package/dist/host/claude/rules/documentation.md +25 -12
  110. package/dist/host/claude/rules/names.md +9 -4
  111. package/dist/host/claude/rules/patterns.md +6 -7
  112. package/dist/host/claude/rules/portability.md +1 -1
  113. package/dist/host/claude/rules/quality.md +4 -5
  114. package/dist/host/claude/rules/styles.md +2 -2
  115. package/dist/host/claude/rules/tests.md +57 -114
  116. package/dist/host/claude/rules/typescript.md +7 -9
  117. package/dist/host/claude/rules/workspace.md +21 -19
  118. package/dist/host/claude/rules/writing.md +2 -5
  119. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  120. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  121. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  122. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  123. package/dist/host/codex/agents/analyst.toml +9 -3
  124. package/dist/host/codex/agents/astra.toml +3 -4
  125. package/dist/host/codex/agents/builder.toml +1 -1
  126. package/dist/host/codex/agents/opus.toml +3 -4
  127. package/dist/host/codex/agents/orkestrel.toml +1 -1
  128. package/dist/host/codex/agents/researcher.toml +2 -2
  129. package/dist/host/codex/agents/verifier.toml +1 -1
  130. package/dist/host/configs/helpers.ts +21 -1
  131. package/dist/host/configs/policy.ts +299 -15
  132. package/dist/host/dotfiles/oxlintrc.json +137 -3
  133. package/dist/host/guides/agent.md +111 -134
  134. package/dist/host/guides/capture.md +801 -0
  135. package/dist/host/guides/probe.md +27 -8
  136. package/dist/host/guides/scaffold.md +96 -18
  137. package/dist/host/guides/study.md +534 -440
  138. package/dist/host/guides/test.md +111 -44
  139. package/dist/host/manifest.json +160 -240
  140. package/dist/host/tests/config.test.ts +971 -187
  141. package/dist/host/tests/policy.test.ts +127 -1
  142. package/dist/host/tests/setupPolicy.ts +893 -33
  143. package/dist/src/core/index.cjs +214 -86
  144. package/dist/src/core/index.cjs.map +1 -1
  145. package/dist/src/core/index.d.cts +122 -38
  146. package/dist/src/core/index.d.ts +122 -38
  147. package/dist/src/core/index.js +209 -85
  148. package/dist/src/core/index.js.map +1 -1
  149. package/dist/src/server/index.cjs +20 -27
  150. package/dist/src/server/index.cjs.map +1 -1
  151. package/dist/src/server/index.js +21 -28
  152. package/dist/src/server/index.js.map +1 -1
  153. package/package.json +11 -11
  154. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  155. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  156. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  157. package/dist/host/guides/supervisor.md +0 -4890
@@ -0,0 +1,560 @@
1
+ // Prove a test catches a mutation: apply an edit file, run a command, restore the files, run the
2
+ // command again. Run from the checkout root:
3
+ // node .agents/skills/orkestrel-falsify/scripts/mutate.ts --edits EDITS.md -- COMMAND [ARGS...]
4
+ // The edit file has the shape `patch.ts` of the `orkestrel-dispatch` skill reads: blocks of a
5
+ // `### <path>` heading (relative to the working directory), a `reason:` line, and fenced `old:` and
6
+ // `new:` texts, where each `old` text must occur exactly once in its file after the earlier edits to
7
+ // that file. The script resolves every edit before it touches a file, saves each file's original
8
+ // bytes under `tmp/probes/mutate/`, writes the mutated files, runs the command with no shell and
9
+ // its output sent to this script's standard error, restores the original bytes and proves each
10
+ // file's SHA-256 digest equals the original, runs the command again, and prints one JSON line:
11
+ // `{"edits":N,"files":[{"path":"...","digest":"..."}],"mutatedExit":N,"restoredExit":N,"restored":true}`.
12
+ // The saved bytes are deleted once the restore is proven. Edits that spell one file two ways
13
+ // (`a.ts` and `./a.ts`, or a case-only difference on a host that folds case) apply to it in order,
14
+ // each against the text the earlier ones left. A mutation that leaves the suite uncollected also
15
+ // reads as a failing run, so read the command's own test count on standard error.
16
+ // A killed parent leaves the tree mutated, so a run refuses to start while a run directory remains
17
+ // and names the line that restores it, and
18
+ // node .agents/skills/orkestrel-falsify/scripts/mutate.ts --restore tmp/probes/mutate/run-NAME
19
+ // writes each saved original back, proves each file's SHA-256 digest against the run's manifest,
20
+ // deletes the run directory, and prints `{"files":[{"path":"...","digest":"..."}],"restored":true}`.
21
+ // The script refuses a flag it does not read before the bare `--`, the `--flag=value` form included,
22
+ // and names an unknown flag before a flag given with no value.
23
+ // Exit 0 when the mutated run failed and the restored run passed (or a `--restore` is proven), 3
24
+ // when either did not, 2 when a restore is not proven or the command cannot start, 64 on usage, an
25
+ // edit that does not resolve, or a run directory an earlier run left.
26
+ import { spawnSync } from 'node:child_process';
27
+ import { createHash } from 'node:crypto';
28
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
30
+ /** Lists the flags that take a value. */
31
+ export const VALUE_FLAGS = ['--edits', '--restore'];
32
+ /** Lists every flag the script reads, value flags and switches alike. */
33
+ export const FLAGS = VALUE_FLAGS;
34
+ /** Holds the usage line printed when a call names no edit file or no command. */
35
+ export const USAGE = 'usage: mutate.ts --edits EDITS.md -- COMMAND [ARGS...] | mutate.ts --restore tmp/probes/mutate/run-NAME';
36
+ /** Holds the directory, under the working directory, that keeps the original bytes of a run. */
37
+ export const BACKUP_ROOT = 'tmp/probes/mutate';
38
+ /** Holds the name every run directory opens with. */
39
+ export const RUN_PREFIX = 'run-';
40
+ /** Holds the name of the manifest file a run directory keeps beside its saved bytes. */
41
+ export const MANIFEST = 'manifest.json';
42
+ /** Matches one edit block with its path, reason, and fenced old and new texts. */
43
+ export const EDIT_BLOCK = /^### (.+?)[ \t]*\n+reason:[ \t]*(.*?)[ \t]*\n+old:\n+(`{3,})[^\n`]*\n([\s\S]*?)\n\3[ \t]*\n+new:\n+(`{3,})[^\n`]*\n([\s\S]*?)\n?\5[ \t]*$/gmu;
44
+ /** Matches a line that belongs to an edit block and so must not remain outside one. */
45
+ export const STRAY_LINE = /^(?:### |reason:|old:|new:)/u;
46
+ /**
47
+ * Names the value flags a caller gave with no value.
48
+ *
49
+ * @param argv - The arguments before the bare `--`.
50
+ * @param names - The flags that take a value.
51
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
52
+ */
53
+ export function readMissingFlags(argv, names) {
54
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined);
55
+ }
56
+ /**
57
+ * Reads the value that follows a flag.
58
+ *
59
+ * @remarks
60
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
61
+ * and the `--flag=value` form is not read.
62
+ *
63
+ * @param argv - The arguments before the bare `--`.
64
+ * @param name - The flag, such as `--edits`.
65
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
66
+ * another flag.
67
+ */
68
+ export function readOption(argv, name) {
69
+ const index = argv.indexOf(name);
70
+ const value = index === -1 ? undefined : argv[index + 1];
71
+ return value === undefined || value.startsWith('--') ? undefined : value;
72
+ }
73
+ /**
74
+ * Names the flags a caller gave that the script does not read.
75
+ *
76
+ * @remarks
77
+ * A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
78
+ * scan, because it opens the command the script runs.
79
+ *
80
+ * @param argv - The arguments after the script path.
81
+ * @param flags - Every flag the script reads, value flags and switches alike.
82
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
83
+ */
84
+ export function readUnknownFlags(argv, flags) {
85
+ const end = argv.indexOf('--');
86
+ return (end === -1 ? argv : argv.slice(0, end)).filter((argument) => argument.startsWith('--') && !flags.includes(argument));
87
+ }
88
+ /**
89
+ * Reads the arguments that are neither a flag nor the value of a value flag.
90
+ *
91
+ * @param argv - The arguments before the bare `--`.
92
+ * @param valueFlags - The flags that take a value.
93
+ * @returns The positional arguments in order.
94
+ */
95
+ export function readPositionals(argv, valueFlags) {
96
+ return argv.filter((argument, index) => !argument.startsWith('--') && !valueFlags.includes(argv[index - 1] ?? ''));
97
+ }
98
+ /**
99
+ * Quotes an untrusted text for a diagnostic, JSON-encoded and cut to a bounded length.
100
+ *
101
+ * @param text - The text to quote.
102
+ * @returns The JSON string literal of the text, ending in an ellipsis inside the quotes when cut.
103
+ */
104
+ export function quote(text) {
105
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
106
+ }
107
+ /**
108
+ * Reads the edit blocks of an edit file in order.
109
+ *
110
+ * @param text - The edit file's text; a carriage return before a line break is ignored.
111
+ * @returns Each complete block as an edit numbered from 1.
112
+ */
113
+ export function parseEdits(text) {
114
+ return [...text.replace(/\r\n/gu, '\n').matchAll(EDIT_BLOCK)].map((match, index) => ({
115
+ number: index + 1,
116
+ path: match[1] ?? '',
117
+ before: match[4] ?? '',
118
+ after: match[6] ?? '',
119
+ }));
120
+ }
121
+ /**
122
+ * Finds the lines of an edit file that open or continue a block no complete block holds.
123
+ *
124
+ * @param text - The edit file's text.
125
+ * @returns Each stray `###`, `reason:`, `old:`, or `new:` line, in order.
126
+ */
127
+ export function readStrays(text) {
128
+ return text
129
+ .replace(/\r\n/gu, '\n')
130
+ .replace(EDIT_BLOCK, '')
131
+ .split(/\r\n|\n/u)
132
+ .filter((line) => STRAY_LINE.test(line));
133
+ }
134
+ /**
135
+ * Counts how many places a text occurs in another, overlapping places included.
136
+ *
137
+ * @param text - The text to search.
138
+ * @param part - The text to find; an empty text occurs nowhere.
139
+ * @returns The number of start positions at which `part` occurs.
140
+ */
141
+ export function countOccurrences(text, part) {
142
+ if (part === '')
143
+ return 0;
144
+ const escaped = part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
145
+ return [...text.matchAll(new RegExp(`(?=${escaped})`, 'gu'))].length;
146
+ }
147
+ /**
148
+ * Checks whether a path sits strictly inside a directory.
149
+ *
150
+ * @param root - The directory.
151
+ * @param target - The path to check.
152
+ * @returns True when `target` is below `root`; false for `root` itself and for any path outside.
153
+ */
154
+ export function isInside(root, target) {
155
+ const path = relative(root, target);
156
+ return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
157
+ }
158
+ /**
159
+ * Hashes bytes with SHA-256.
160
+ *
161
+ * @param bytes - The bytes to hash.
162
+ * @returns The digest as lowercase hexadecimal.
163
+ */
164
+ export function digestBytes(bytes) {
165
+ return createHash('sha256').update(bytes).digest('hex');
166
+ }
167
+ /**
168
+ * Applies one file's edits in order, each against the text the earlier edits left.
169
+ *
170
+ * @param text - The file's text before any edit.
171
+ * @param edits - The file's edits in file order.
172
+ * @returns The text after the edits, and one problem per edit whose `old` text does not occur
173
+ * exactly once; such an edit changes nothing.
174
+ */
175
+ export function applyEdits(text, edits) {
176
+ return edits.reduce((state, edit) => {
177
+ const count = countOccurrences(state.text, edit.before);
178
+ return count === 1
179
+ ? { text: state.text.replace(edit.before, () => edit.after), problems: state.problems }
180
+ : {
181
+ text: state.text,
182
+ problems: [
183
+ ...state.problems,
184
+ `#${String(edit.number)} ${edit.path}: old text occurs ${String(count)} times`,
185
+ ],
186
+ };
187
+ }, { text, problems: [] });
188
+ }
189
+ /**
190
+ * Folds a path's case on a host whose file names ignore case.
191
+ *
192
+ * @param path - The path to fold.
193
+ * @param platform - The host platform, as `process.platform` names it.
194
+ * @returns The path in lowercase on `win32` and `darwin`; the path unchanged elsewhere.
195
+ */
196
+ export function foldPath(path, platform) {
197
+ return platform === 'win32' || platform === 'darwin' ? path.toLowerCase() : path;
198
+ }
199
+ /**
200
+ * Names the file an edit changes, so two spellings of one path share a name.
201
+ *
202
+ * @param root - The working directory the edit paths resolve against.
203
+ * @param edit - The edit.
204
+ * @returns The edit's resolved path, case-folded where the host folds case.
205
+ */
206
+ export function keyEdit(root, edit) {
207
+ return foldPath(resolve(root, edit.path), process.platform);
208
+ }
209
+ /**
210
+ * Resolves every edit against the files under a root, writing nothing.
211
+ *
212
+ * @remarks
213
+ * Edits that name one file under two spellings are one file's edits, applied in file order.
214
+ *
215
+ * @param root - The working directory the edit paths resolve against.
216
+ * @param edits - The edits in file order.
217
+ * @returns One mutation per file whose edits all resolved, and each problem found in any file.
218
+ */
219
+ export function resolveMutations(root, edits) {
220
+ const results = [...new Set(edits.map((edit) => keyEdit(root, edit)))].map((key) => {
221
+ const own = edits.filter((edit) => keyEdit(root, edit) === key);
222
+ const path = own[0]?.path ?? '';
223
+ const target = resolve(root, path);
224
+ if (!isInside(root, target)) {
225
+ return { mutation: undefined, problems: [`${path}: path leaves the working directory`] };
226
+ }
227
+ if (!existsSync(target) || !statSync(target).isFile()) {
228
+ return { mutation: undefined, problems: [`${path}: no such file`] };
229
+ }
230
+ const original = readFileSync(target);
231
+ const applied = applyEdits(original.toString('utf8'), own);
232
+ return {
233
+ mutation: applied.problems.length === 0 ? { path, original, mutated: applied.text } : undefined,
234
+ problems: applied.problems,
235
+ };
236
+ });
237
+ return {
238
+ mutations: results.flatMap((result) => result.mutation === undefined ? [] : [result.mutation]),
239
+ problems: results.flatMap((result) => result.problems),
240
+ };
241
+ }
242
+ /**
243
+ * Runs the command with no shell, its output sent to this process's standard error.
244
+ *
245
+ * @param command - The executable to run.
246
+ * @param args - The arguments after the executable.
247
+ * @returns The exit code, or undefined with the reason when the command cannot start or a signal
248
+ * ended it.
249
+ */
250
+ export function runCommand(command, args) {
251
+ const result = spawnSync(command, args, {
252
+ stdio: ['ignore', 2, 2],
253
+ windowsHide: true,
254
+ shell: false,
255
+ });
256
+ const error = result.error?.message ?? (result.signal === null ? undefined : `ended by ${result.signal}`);
257
+ return { exit: error === undefined ? (result.status ?? undefined) : undefined, error };
258
+ }
259
+ /**
260
+ * Writes each mutation's original bytes back to its file.
261
+ *
262
+ * @param root - The working directory the paths resolve against.
263
+ * @param mutations - The mutations to undo.
264
+ * @returns Nothing after every file is written.
265
+ */
266
+ export function restoreFiles(root, mutations) {
267
+ for (const mutation of mutations)
268
+ writeFileSync(resolve(root, mutation.path), mutation.original);
269
+ }
270
+ /**
271
+ * Names each file whose bytes no longer match the original digest.
272
+ *
273
+ * @param root - The working directory the paths resolve against.
274
+ * @param mutations - The mutations whose originals are the reference.
275
+ * @returns The path of each file that is missing or whose digest differs, in mutation order.
276
+ */
277
+ export function readDrift(root, mutations) {
278
+ return mutations.flatMap((mutation) => digestFile(resolve(root, mutation.path)) === digestBytes(mutation.original)
279
+ ? []
280
+ : [mutation.path]);
281
+ }
282
+ /**
283
+ * Hashes a file's bytes with SHA-256.
284
+ *
285
+ * @param path - The file to hash.
286
+ * @returns The digest as lowercase hexadecimal, or undefined when the path cannot be read as a file.
287
+ */
288
+ export function digestFile(path) {
289
+ try {
290
+ return digestBytes(readFileSync(path));
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ }
296
+ /**
297
+ * Writes the mutated files and runs the command, reporting a write the host refuses as a run that
298
+ * never exited.
299
+ *
300
+ * @param root - The working directory the paths resolve against.
301
+ * @param mutations - The mutations to apply.
302
+ * @param command - The executable to run.
303
+ * @param args - The arguments after the executable.
304
+ * @returns How the mutated run ended.
305
+ */
306
+ export function runMutated(root, mutations, command, args) {
307
+ try {
308
+ for (const mutation of mutations)
309
+ writeFileSync(resolve(root, mutation.path), mutation.mutated);
310
+ return runCommand(command, args);
311
+ }
312
+ catch (error) {
313
+ return { exit: undefined, error: error instanceof Error ? error.message : String(error) };
314
+ }
315
+ }
316
+ /**
317
+ * Restores every file and names the refusal when the host rejects a write.
318
+ *
319
+ * @param root - The working directory the paths resolve against.
320
+ * @param mutations - The mutations to undo.
321
+ * @returns The host's refusal message, or undefined when every file was written back.
322
+ */
323
+ export function tryRestore(root, mutations) {
324
+ try {
325
+ restoreFiles(root, mutations);
326
+ return undefined;
327
+ }
328
+ catch (error) {
329
+ return error instanceof Error ? error.message : String(error);
330
+ }
331
+ }
332
+ /**
333
+ * Saves each file's original bytes and a manifest of their digests in a new directory.
334
+ *
335
+ * @param root - The working directory the backup root sits under.
336
+ * @param mutations - The mutations whose originals to save.
337
+ * @returns The directory that holds the saved bytes.
338
+ */
339
+ export function saveOriginals(root, mutations) {
340
+ const base = resolve(root, BACKUP_ROOT);
341
+ mkdirSync(base, { recursive: true });
342
+ const directory = mkdtempSync(join(base, RUN_PREFIX));
343
+ for (const [index, mutation] of mutations.entries()) {
344
+ writeFileSync(join(directory, `${String(index)}.bak`), mutation.original);
345
+ }
346
+ writeFileSync(join(directory, MANIFEST), JSON.stringify(mutations.map((mutation, index) => ({
347
+ path: mutation.path,
348
+ backup: `${String(index)}.bak`,
349
+ digest: digestBytes(mutation.original),
350
+ }))));
351
+ return directory;
352
+ }
353
+ /**
354
+ * Lists the run directories an earlier run left under the backup root.
355
+ *
356
+ * @param root - The working directory the backup root sits under.
357
+ * @returns The name of each run directory, sorted; empty when none remains.
358
+ */
359
+ export function readLeftovers(root) {
360
+ const base = resolve(root, BACKUP_ROOT);
361
+ return existsSync(base)
362
+ ? readdirSync(base)
363
+ .filter((name) => name.startsWith(RUN_PREFIX))
364
+ .sort()
365
+ : [];
366
+ }
367
+ /**
368
+ * Checks whether a value is one entry of a run directory's manifest.
369
+ *
370
+ * @param value - The parsed JSON value.
371
+ * @returns True when the value names a path, a backup file with no directory part, and a digest.
372
+ */
373
+ export function isManifestEntry(value) {
374
+ return (typeof value === 'object' &&
375
+ value !== null &&
376
+ 'path' in value &&
377
+ typeof value.path === 'string' &&
378
+ 'backup' in value &&
379
+ typeof value.backup === 'string' &&
380
+ value.backup !== '' &&
381
+ basename(value.backup) === value.backup &&
382
+ 'digest' in value &&
383
+ typeof value.digest === 'string');
384
+ }
385
+ /**
386
+ * Reads the manifest of a run directory.
387
+ *
388
+ * @param directory - The run directory.
389
+ * @returns Each saved file in order, or undefined when the manifest is missing, is not JSON, or
390
+ * holds anything but entries.
391
+ */
392
+ export function readManifest(directory) {
393
+ try {
394
+ const parsed = JSON.parse(readFileSync(join(directory, MANIFEST), 'utf8'));
395
+ return Array.isArray(parsed) && parsed.every(isManifestEntry) ? parsed : undefined;
396
+ }
397
+ catch {
398
+ return undefined;
399
+ }
400
+ }
401
+ /**
402
+ * Writes each saved file's bytes back to its path and names the refusal when the host rejects a
403
+ * write.
404
+ *
405
+ * @param root - The working directory the paths resolve against.
406
+ * @param directory - The run directory that holds the saved bytes.
407
+ * @param entries - The manifest entries to write back.
408
+ * @returns The host's refusal message, or undefined when every file was written.
409
+ */
410
+ export function writeSaved(root, directory, entries) {
411
+ try {
412
+ for (const entry of entries) {
413
+ writeFileSync(resolve(root, entry.path), readFileSync(join(directory, entry.backup)));
414
+ }
415
+ return undefined;
416
+ }
417
+ catch (error) {
418
+ return error instanceof Error ? error.message : String(error);
419
+ }
420
+ }
421
+ /**
422
+ * Restores the files a run directory saved and proves each digest from its manifest.
423
+ *
424
+ * @param root - The working directory the manifest paths resolve against.
425
+ * @param argument - The run directory as the caller gave it.
426
+ * @returns The exit code: 0 when every file is back and proven and the run directory is deleted, 2
427
+ * when a saved file no longer matches its digest or a restore is not proven, 64 when the argument
428
+ * is no run directory of this script or its manifest cannot be trusted.
429
+ */
430
+ export function restoreRun(root, argument) {
431
+ const base = resolve(root, BACKUP_ROOT);
432
+ const directory = resolve(root, argument);
433
+ if (foldPath(dirname(directory), process.platform) !== foldPath(base, process.platform) ||
434
+ !basename(directory).startsWith(RUN_PREFIX)) {
435
+ console.error(`mutate: ${quote(argument)} is not a run directory under ${BACKUP_ROOT}; ${USAGE}`);
436
+ return 64;
437
+ }
438
+ const entries = readManifest(directory);
439
+ if (entries === undefined) {
440
+ console.error(`mutate: ${quote(argument)} holds no readable ${MANIFEST}; ${USAGE}`);
441
+ return 64;
442
+ }
443
+ const outside = entries.filter((entry) => !isInside(root, resolve(root, entry.path)));
444
+ if (outside.length > 0) {
445
+ console.error(`mutate: ${MANIFEST} names a path outside the working directory: ${outside.map((entry) => quote(entry.path)).join(', ')}; nothing was written`);
446
+ return 64;
447
+ }
448
+ const damaged = entries.filter((entry) => digestFile(join(directory, entry.backup)) !== entry.digest);
449
+ if (damaged.length > 0) {
450
+ console.error(`mutate: the saved bytes of ${damaged.map((entry) => entry.path).join(', ')} no longer match ${MANIFEST}; nothing was written`);
451
+ return 2;
452
+ }
453
+ const refusal = writeSaved(root, directory, entries);
454
+ const drift = entries.filter((entry) => digestFile(resolve(root, entry.path)) !== entry.digest);
455
+ if (refusal !== undefined || drift.length > 0) {
456
+ console.error(`mutate: restore not proven for ${drift.map((entry) => entry.path).join(', ')}${refusal === undefined ? '' : ` (${refusal})`}; the originals stay under ${directory}`);
457
+ return 2;
458
+ }
459
+ rmSync(directory, { recursive: true, force: true });
460
+ console.log(JSON.stringify({
461
+ files: entries.map((entry) => ({ path: entry.path, digest: entry.digest })),
462
+ restored: true,
463
+ }));
464
+ return 0;
465
+ }
466
+ /**
467
+ * Runs the script on its arguments.
468
+ *
469
+ * @param argv - The arguments after the script path.
470
+ * @returns The exit code: 0 when the mutated run failed and the restored run passed or a `--restore`
471
+ * is proven, 3 when either did not, 2 when a restore is not proven or the command cannot start, 64
472
+ * on usage, an edit that does not resolve, or a run directory an earlier run left.
473
+ */
474
+ export function main(argv) {
475
+ const end = argv.indexOf('--');
476
+ const own = end === -1 ? argv : argv.slice(0, end);
477
+ const command = end === -1 ? [] : argv.slice(end + 1);
478
+ const unknown = readUnknownFlags(argv, FLAGS);
479
+ if (unknown.length > 0) {
480
+ console.error(`mutate: ${unknown.join(', ')} is not a flag this script reads; ${USAGE}`);
481
+ return 64;
482
+ }
483
+ const missing = readMissingFlags(own, VALUE_FLAGS);
484
+ if (missing.length > 0) {
485
+ console.error(`mutate: ${missing.join(', ')} given with no value`);
486
+ return 64;
487
+ }
488
+ const restore = readOption(own, '--restore');
489
+ if (restore !== undefined) {
490
+ if (end !== -1 || own.includes('--edits') || readPositionals(own, VALUE_FLAGS).length > 0) {
491
+ console.error(USAGE);
492
+ return 64;
493
+ }
494
+ return restoreRun(process.cwd(), restore);
495
+ }
496
+ const source = readOption(own, '--edits');
497
+ const executable = command[0];
498
+ if (source === undefined ||
499
+ executable === undefined ||
500
+ readPositionals(own, VALUE_FLAGS).length > 0) {
501
+ console.error(USAGE);
502
+ return 64;
503
+ }
504
+ if (!existsSync(source) || !statSync(source).isFile()) {
505
+ console.error(`mutate: ${quote(source)} is not a readable edit file; ${USAGE}`);
506
+ return 64;
507
+ }
508
+ const text = readFileSync(source, 'utf8');
509
+ const edits = parseEdits(text);
510
+ const strays = readStrays(text);
511
+ if (edits.length === 0 || strays.length > 0) {
512
+ console.error(`mutate: ${quote(source)} ${edits.length === 0 ? 'holds no complete edit block' : `has a line that opens no complete block: ${strays.map(quote).join(', ')}`}; a block is a "### path" line, a "reason:" line, and fenced "old:" and "new:" texts`);
513
+ return 64;
514
+ }
515
+ const root = process.cwd();
516
+ const leftovers = readLeftovers(root);
517
+ if (leftovers.length > 0) {
518
+ console.error(`mutate: an earlier run left its saved originals, so the tree may still be mutated; no file was touched and no command ran. Restore it first: ${leftovers.map((name) => `mutate.ts --restore ${BACKUP_ROOT}/${name}`).join('; ')}`);
519
+ return 64;
520
+ }
521
+ const resolution = resolveMutations(root, edits);
522
+ if (resolution.problems.length > 0) {
523
+ console.error(`mutate: no file was touched and no command ran: ${resolution.problems.join('; ')}`);
524
+ return 64;
525
+ }
526
+ const saved = saveOriginals(root, resolution.mutations);
527
+ console.error(`mutate: originals saved under ${saved}`);
528
+ const mutated = runMutated(root, resolution.mutations, executable, command.slice(1));
529
+ const refusal = tryRestore(root, resolution.mutations);
530
+ const drift = readDrift(root, resolution.mutations);
531
+ if (refusal !== undefined || drift.length > 0) {
532
+ console.error(`mutate: restore not proven for ${drift.join(', ')}${refusal === undefined ? '' : ` (${refusal})`}; the originals are under ${saved}`);
533
+ return 2;
534
+ }
535
+ if (mutated.exit === undefined) {
536
+ rmSync(saved, { recursive: true, force: true });
537
+ console.error(`mutate: the command did not exit: ${mutated.error ?? 'unknown'}`);
538
+ return 2;
539
+ }
540
+ const restored = runCommand(executable, command.slice(1));
541
+ rmSync(saved, { recursive: true, force: true });
542
+ if (restored.exit === undefined) {
543
+ console.error(`mutate: the restored run did not exit: ${restored.error ?? 'unknown'}`);
544
+ return 2;
545
+ }
546
+ console.log(JSON.stringify({
547
+ edits: edits.length,
548
+ files: resolution.mutations.map((mutation) => ({
549
+ path: mutation.path,
550
+ digest: digestBytes(mutation.original),
551
+ })),
552
+ mutatedExit: mutated.exit,
553
+ restoredExit: restored.exit,
554
+ restored: true,
555
+ }));
556
+ return mutated.exit !== 0 && restored.exit === 0 ? 0 : 3;
557
+ }
558
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
559
+ process.exitCode = main(process.argv.slice(2));
560
+ }