@orkestrel/scaffold 0.0.107 → 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 (164) 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 +368 -27
  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 +26 -20
  25. package/dist/host/agents/orchestration.md +98 -58
  26. package/dist/host/agents/platforms/posix.md +15 -0
  27. package/dist/host/agents/platforms/windows.md +37 -0
  28. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  29. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  30. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  31. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  32. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  33. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  34. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  35. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  36. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  37. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +12 -14
  38. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +36 -24
  39. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +14 -11
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  48. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +428 -27
  49. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  50. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  51. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  52. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  53. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  54. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  55. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  56. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  58. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  59. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  60. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  63. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  64. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  65. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  66. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  67. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  68. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  69. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +91 -30
  70. package/dist/host/agents/skills/orkestrel-parallel/agents/openai.yaml +2 -2
  71. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +34 -0
  72. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +29 -0
  73. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +29 -0
  74. package/dist/host/agents/skills/orkestrel-parallel/references/t3.md +31 -0
  75. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  76. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  77. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  78. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  79. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  80. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  81. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  82. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -10
  83. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  86. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  87. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  88. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  89. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  90. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  91. package/dist/host/agents/templates/brief.md +1 -1
  92. package/dist/host/agents/transports/claude-cli.md +3 -3
  93. package/dist/host/agents/transports/codex.md +3 -8
  94. package/dist/host/agents/transports/cursor.md +2 -3
  95. package/dist/host/claude/AGENTS.md +7 -10
  96. package/dist/host/claude/agents/analyst.md +3 -3
  97. package/dist/host/claude/agents/astra.md +3 -3
  98. package/dist/host/claude/agents/builder.md +1 -1
  99. package/dist/host/claude/agents/checker.md +1 -1
  100. package/dist/host/claude/agents/distiller.md +1 -1
  101. package/dist/host/claude/agents/grok.md +1 -1
  102. package/dist/host/claude/agents/opus.md +2 -2
  103. package/dist/host/claude/agents/orkestrel.md +57 -90
  104. package/dist/host/claude/agents/researcher.md +1 -1
  105. package/dist/host/claude/agents/reviewer.md +4 -0
  106. package/dist/host/claude/agents/scout.md +1 -1
  107. package/dist/host/claude/agents/verifier.md +1 -1
  108. package/dist/host/claude/rules/application.md +14 -21
  109. package/dist/host/claude/rules/architecture.md +34 -44
  110. package/dist/host/claude/rules/browser.md +3 -4
  111. package/dist/host/claude/rules/campaign.md +55 -0
  112. package/dist/host/claude/rules/documentation.md +26 -11
  113. package/dist/host/claude/rules/names.md +9 -4
  114. package/dist/host/claude/rules/patterns.md +6 -7
  115. package/dist/host/claude/rules/portability.md +2 -3
  116. package/dist/host/claude/rules/quality.md +4 -5
  117. package/dist/host/claude/rules/styles.md +2 -2
  118. package/dist/host/claude/rules/tests.md +57 -114
  119. package/dist/host/claude/rules/typescript.md +8 -10
  120. package/dist/host/claude/rules/workspace.md +21 -19
  121. package/dist/host/claude/rules/writing.md +3 -3
  122. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  123. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  124. package/dist/host/claude/skills/orkestrel-parallel/SKILL.md +1 -1
  125. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  126. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  127. package/dist/host/codex/agents/analyst.toml +9 -3
  128. package/dist/host/codex/agents/astra.toml +3 -4
  129. package/dist/host/codex/agents/builder.toml +1 -1
  130. package/dist/host/codex/agents/opus.toml +3 -4
  131. package/dist/host/codex/agents/orkestrel.toml +1 -1
  132. package/dist/host/codex/agents/researcher.toml +2 -2
  133. package/dist/host/codex/agents/verifier.toml +1 -1
  134. package/dist/host/codex/config.toml +3 -3
  135. package/dist/host/configs/helpers.ts +21 -1
  136. package/dist/host/configs/policy.ts +405 -16
  137. package/dist/host/cursor/rules/orchestration.mdc +1 -0
  138. package/dist/host/dotfiles/oxlintrc.json +141 -5
  139. package/dist/host/guides/agent.md +111 -134
  140. package/dist/host/guides/capture.md +801 -0
  141. package/dist/host/guides/probe.md +27 -8
  142. package/dist/host/guides/scaffold.md +152 -19
  143. package/dist/host/guides/study.md +534 -440
  144. package/dist/host/guides/test.md +111 -44
  145. package/dist/host/manifest.json +199 -242
  146. package/dist/host/tests/config.test.ts +1558 -219
  147. package/dist/host/tests/policy.test.ts +127 -1
  148. package/dist/host/tests/setupPolicy.ts +1389 -39
  149. package/dist/src/core/index.cjs +239 -87
  150. package/dist/src/core/index.cjs.map +1 -1
  151. package/dist/src/core/index.d.cts +123 -39
  152. package/dist/src/core/index.d.ts +123 -39
  153. package/dist/src/core/index.js +234 -86
  154. package/dist/src/core/index.js.map +1 -1
  155. package/dist/src/server/index.cjs +20 -27
  156. package/dist/src/server/index.cjs.map +1 -1
  157. package/dist/src/server/index.js +21 -28
  158. package/dist/src/server/index.js.map +1 -1
  159. package/package.json +11 -11
  160. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  161. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  162. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  163. package/dist/host/agents/skills/orkestrel-parallel/references/harnesses.md +0 -19
  164. package/dist/host/guides/supervisor.md +0 -4890
@@ -6,32 +6,183 @@
6
6
  // break ends the last line. A citation naming a bare filename that no root-relative
7
7
  // file matches resolves when exactly one file under the root carries that name, outside
8
8
  // node_modules, dist, tmp, .git, .orkestrel, and .idea. The summary lists every citation that does
9
- // not resolve, with the reason. Exit 0 when all resolve, 3 when any does not, 64 on usage.
10
- import { existsSync, readFileSync, statSync } from 'node:fs'
11
- import { basename, relative, resolve } from 'node:path'
12
- import { listFiles, normalizeSlashes, readMissingFlags, readOption } from './helpers.ts'
9
+ // not resolve, with the reason. The script refuses a flag it does not read, the `--flag=value` form
10
+ // included. Exit 0 when all resolve, 3 when any does not, 64 on usage.
11
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
12
+ import { basename, join, relative, resolve } from 'node:path'
13
13
 
14
- const CITATION =
14
+ /** Matches one `file:line` or `file:line-line` citation in a text. */
15
+ export const CITATION =
15
16
  /(?<![\w/\\.-])((?:\.{1,2}[/\\])?\.?[A-Za-z0-9_@][A-Za-z0-9_./\\@-]*\.[a-z]{1,5}):(\d+)(?:[-–](\d+))?(?![\w:])/gu
16
17
 
17
- interface Unresolved {
18
+ /** Names the directories a tree walk skips. */
19
+ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
20
+ 'node_modules',
21
+ '.git',
22
+ 'dist',
23
+ 'tmp',
24
+ '.orkestrel',
25
+ '.idea',
26
+ ])
27
+
28
+ /** Lists the flags that take a value. */
29
+ export const VALUE_FLAGS: readonly string[] = ['--root']
30
+
31
+ /** Lists every flag the script reads, value flags and switches alike. */
32
+ export const FLAGS: readonly string[] = [...VALUE_FLAGS, '--json']
33
+
34
+ /** Holds the usage line printed when a call names no report. */
35
+ export const USAGE = 'usage: cite.ts PATH [--root DIR] [--json]'
36
+
37
+ /** Describes one citation that did not resolve and why. */
38
+ export interface Unresolved {
18
39
  readonly citation: string
19
40
  readonly reason: string
20
41
  }
21
42
 
22
- interface Resolution {
43
+ /** Describes where a citation's file resolved, or why it did not. */
44
+ export interface Resolution {
23
45
  readonly path: string | undefined
24
46
  readonly reason: string | undefined
25
47
  }
26
48
 
27
- interface Citation {
49
+ /** Describes one citation read from a text: its file, its line, and the end of its range. */
50
+ export interface Citation {
28
51
  readonly citation: string
29
52
  readonly file: string
30
53
  readonly line: number
31
54
  readonly end: number | undefined
32
55
  }
33
56
 
34
- function indexBasenames(root: string): ReadonlyMap<string, readonly string[]> {
57
+ /** Describes one file a tree walk found, with the modification time it carries. */
58
+ export interface WalkedFile {
59
+ readonly path: string
60
+ readonly modified: number
61
+ }
62
+
63
+ /**
64
+ * Reads the value that follows a flag.
65
+ *
66
+ * @remarks
67
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
68
+ * and the `--flag=value` form is not read.
69
+ *
70
+ * @param argv - The arguments after the script path.
71
+ * @param name - The flag, such as `--root`.
72
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
73
+ * another flag.
74
+ */
75
+ export function readOption(argv: readonly string[], name: string): string | undefined {
76
+ const index = argv.indexOf(name)
77
+ const value = index === -1 ? undefined : argv[index + 1]
78
+ return value === undefined || value.startsWith('--') ? undefined : value
79
+ }
80
+
81
+ /**
82
+ * Names the value flags a caller gave with no value.
83
+ *
84
+ * @param argv - The arguments after the script path.
85
+ * @param names - The flags that take a value.
86
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
87
+ */
88
+ export function readMissingFlags(
89
+ argv: readonly string[],
90
+ names: readonly string[],
91
+ ): readonly string[] {
92
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
93
+ }
94
+
95
+ /**
96
+ * Names the flags a caller gave that the script does not read.
97
+ *
98
+ * @remarks
99
+ * A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
100
+ * scan.
101
+ *
102
+ * @param argv - The arguments after the script path.
103
+ * @param flags - Every flag the script reads, value flags and switches alike.
104
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
105
+ */
106
+ export function readUnknownFlags(
107
+ argv: readonly string[],
108
+ flags: readonly string[],
109
+ ): readonly string[] {
110
+ const end = argv.indexOf('--')
111
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
112
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Normalizes a host path to forward slashes.
118
+ *
119
+ * @param path - A host path.
120
+ * @returns The same path with every backslash replaced by a slash.
121
+ */
122
+ export function normalizeSlashes(path: string): string {
123
+ return path.split('\\').join('/')
124
+ }
125
+
126
+ /**
127
+ * Lists every regular file beneath a directory, skipping the directories the script never reads.
128
+ *
129
+ * @remarks
130
+ * A skipped name matches a directory at any depth, exactly and case-sensitively.
131
+ *
132
+ * @param directory - The directory to walk.
133
+ * @param skipped - The directory names to skip. Default: the skipped directories.
134
+ * @returns Each file's forward-slash path and modification time, in walk order.
135
+ * @throws Thrown when `directory` does not exist or cannot be read.
136
+ */
137
+ export function listFiles(
138
+ directory: string,
139
+ skipped: ReadonlySet<string> = SKIPPED_DIRECTORIES,
140
+ ): readonly WalkedFile[] {
141
+ const files: WalkedFile[] = []
142
+ const pending = [directory]
143
+ while (pending.length > 0) {
144
+ const current = pending.pop()
145
+ if (current === undefined) break
146
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
147
+ if (skipped.has(entry.name)) continue
148
+ const path = join(current, entry.name)
149
+ if (entry.isDirectory()) pending.push(path)
150
+ else if (entry.isFile()) {
151
+ files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs })
152
+ }
153
+ }
154
+ }
155
+ return files
156
+ }
157
+
158
+ /**
159
+ * Extracts every citation a text carries.
160
+ *
161
+ * @param text - The report text.
162
+ * @returns Each citation in text order, with its file, its line, and the end of a range.
163
+ */
164
+ export function extractCitations(text: string): readonly Citation[] {
165
+ return [...text.matchAll(CITATION)].flatMap((match): Citation[] =>
166
+ match[1] === undefined
167
+ ? []
168
+ : [
169
+ {
170
+ citation: match[0],
171
+ file: match[1],
172
+ line: Number(match[2]),
173
+ end: match[3] === undefined ? undefined : Number(match[3]),
174
+ },
175
+ ],
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Indexes every file under a root by its basename.
181
+ *
182
+ * @param root - The directory to walk.
183
+ * @returns Each basename mapped to the root-relative forward-slash paths that carry it.
184
+ */
185
+ export function indexBasenames(root: string): ReadonlyMap<string, readonly string[]> {
35
186
  return new Map(
36
187
  [...Map.groupBy(listFiles(root), (file) => basename(file.path))].map(([name, files]) => [
37
188
  name,
@@ -40,7 +191,13 @@ function indexBasenames(root: string): ReadonlyMap<string, readonly string[]> {
40
191
  )
41
192
  }
42
193
 
43
- function countLines(path: string): number {
194
+ /**
195
+ * Counts the lines of a file.
196
+ *
197
+ * @param path - The file to read.
198
+ * @returns The number of lines, where a trailing line break ends the last line and an empty file has none.
199
+ */
200
+ export function countLines(path: string): number {
44
201
  const text = readFileSync(path, 'utf8')
45
202
  if (text === '') return 0
46
203
  const lines = text.split(/\r\n|\n/)
@@ -48,17 +205,38 @@ function countLines(path: string): number {
48
205
  return lines.at(-1) === '' ? lines.length - 1 : lines.length
49
206
  }
50
207
 
51
- function resolveDirect(root: string, file: string): string | undefined {
208
+ /**
209
+ * Resolves a citation's file directly under the root.
210
+ *
211
+ * @param root - The directory a citation's path is relative to.
212
+ * @param file - The cited path, a backslash read as a slash.
213
+ * @returns The absolute path of the file, or undefined when no regular file sits there.
214
+ */
215
+ export function resolveDirect(root: string, file: string): string | undefined {
52
216
  const direct = resolve(root, normalizeSlashes(file))
53
217
  return existsSync(direct) && statSync(direct).isFile() ? direct : undefined
54
218
  }
55
219
 
56
- // A bare filename that no root-relative file matches is the one citation the basename index serves.
57
- function needsIndex(root: string, file: string): boolean {
220
+ /**
221
+ * Checks whether a citation can resolve only through the basename index.
222
+ *
223
+ * @param root - The directory a citation's path is relative to.
224
+ * @param file - The cited path.
225
+ * @returns True if no root-relative file matches and the path is a bare filename; false otherwise.
226
+ */
227
+ export function needsIndex(root: string, file: string): boolean {
58
228
  return resolveDirect(root, file) === undefined && !/[/\\]/u.test(file)
59
229
  }
60
230
 
61
- function resolveFile(
231
+ /**
232
+ * Resolves a citation's file, directly or through the basename index.
233
+ *
234
+ * @param root - The directory a citation's path is relative to.
235
+ * @param file - The cited path.
236
+ * @param index - The basename index, or undefined when no citation needed one.
237
+ * @returns The resolved absolute path, or the reason the file did not resolve.
238
+ */
239
+ export function resolveFile(
62
240
  root: string,
63
241
  file: string,
64
242
  index: ReadonlyMap<string, readonly string[]> | undefined,
@@ -73,7 +251,16 @@ function resolveFile(
73
251
  return { path: undefined, reason: `ambiguous basename: ${candidates.join(', ')}` }
74
252
  }
75
253
 
76
- function judgeLines(
254
+ /**
255
+ * Judges whether a cited line or range lies within a file.
256
+ *
257
+ * @param path - The resolved file.
258
+ * @param line - The cited line.
259
+ * @param end - The end of a cited range, or undefined for a single line.
260
+ * @param counts - The line counts already read, by file.
261
+ * @returns The reason the line or range is outside the file, or undefined when it is inside.
262
+ */
263
+ export function judgeLines(
77
264
  path: string,
78
265
  line: number,
79
266
  end: number | undefined,
@@ -86,8 +273,19 @@ function judgeLines(
86
273
  return undefined
87
274
  }
88
275
 
89
- function main(argv: readonly string[]): number {
90
- const missing = readMissingFlags(argv, ['--root'])
276
+ /**
277
+ * Runs the script on its arguments.
278
+ *
279
+ * @param argv - The arguments after the script path.
280
+ * @returns The exit code: 0 when every citation resolves, 3 when one does not, 64 on usage.
281
+ */
282
+ export function main(argv: readonly string[]): number {
283
+ const unknown = readUnknownFlags(argv, FLAGS)
284
+ if (unknown.length > 0) {
285
+ console.error(`cite: ${unknown.join(', ')} is not a flag this script reads; ${USAGE}`)
286
+ return 64
287
+ }
288
+ const missing = readMissingFlags(argv, VALUE_FLAGS)
91
289
  if (missing.length > 0) {
92
290
  console.error(`cite: ${missing.join(', ')} given with no value`)
93
291
  return 64
@@ -97,21 +295,10 @@ function main(argv: readonly string[]): number {
97
295
  (argument, position) => !argument.startsWith('--') && argv[position - 1] !== '--root',
98
296
  )
99
297
  if (path === undefined || !existsSync(path)) {
100
- console.error('usage: cite.ts PATH [--root DIR] [--json]')
298
+ console.error(USAGE)
101
299
  return 64
102
300
  }
103
- const named = [...readFileSync(path, 'utf8').matchAll(CITATION)].flatMap((match): Citation[] =>
104
- match[1] === undefined
105
- ? []
106
- : [
107
- {
108
- citation: match[0],
109
- file: match[1],
110
- line: Number(match[2]),
111
- end: match[3] === undefined ? undefined : Number(match[3]),
112
- },
113
- ],
114
- )
301
+ const named = extractCitations(readFileSync(path, 'utf8'))
115
302
  const citations = named.length
116
303
  // The basename index walks the root, so it is built once and only when a citation needs it.
117
304
  const index = named.some(({ file }) => needsIndex(root, file)) ? indexBasenames(root) : undefined
@@ -142,4 +329,6 @@ function main(argv: readonly string[]): number {
142
329
  return unresolved.length === 0 ? 0 : 3
143
330
  }
144
331
 
145
- process.exitCode = main(process.argv.slice(2))
332
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
333
+ process.exitCode = main(process.argv.slice(2))
334
+ }
@@ -2,7 +2,8 @@
2
2
  // node .agents/skills/orkestrel-dispatch/scripts/launch.ts --journal tmp/codex/UNIT.jsonl --errors tmp/codex/UNIT.err --cap 1500 [--status] -- codex exec --json ...
3
3
  // The command's stdout goes to --journal and its stderr to --errors; stdin is closed. Arguments after
4
4
  // `--` pass to the command verbatim, so no shell quoting is involved. --cap is in seconds: when it
5
- // expires the process tree is killed and the errors file records `capped=true`. A fractional cap
5
+ // expires the process tree is killed and the errors file records `capped=true`; a command that had
6
+ // already exited when the cap expired is not capped. A fractional cap
6
7
  // truncates to whole milliseconds, with 1 ms the least. --status records
7
8
  // `git status --porcelain` before and after beside the journal, which is how a read-only lane's
8
9
  // containment is checked. Stdout carries up to two JSON lines. The spawn line
@@ -11,31 +12,133 @@
11
12
  // removed before the spawn, so neither appears when the command cannot start. The summary line
12
13
  // prints last, after the command exits, and the errors file ends with `exit=<code>`. At the cap,
13
14
  // Windows kills the process tree; any other host kills the command alone, not its descendants.
14
- // The launcher's own flags are read before `--` alone. Exit: the command's code; 124 when capped;
15
+ // The launcher's own flags are read before `--` alone, and it refuses a flag it does not read there,
16
+ // the `--flag=value` form included. Exit: the command's code; 124 when capped;
15
17
  // 127 when the command could not start (the errors file names why); 64 on usage, including a
16
18
  // journal and errors path that are the same file and a cap that is not a number of seconds from 0
17
19
  // to 2147483.
20
+ import type { ChildProcess } from 'node:child_process'
18
21
  import { spawn, spawnSync } from 'node:child_process'
19
- import { appendFileSync, closeSync, mkdirSync, openSync, rmSync, writeFileSync } from 'node:fs'
22
+ import {
23
+ appendFileSync,
24
+ closeSync,
25
+ mkdirSync,
26
+ openSync,
27
+ realpathSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from 'node:fs'
20
31
  import { dirname } from 'node:path'
21
- import { readMissingFlags, readOption } from './helpers.ts'
22
32
 
23
- // The longest wait a Node timer holds is 2147483647 ms; floored to whole seconds, a longer cap would
24
- // throw from `AbortSignal.timeout` after the spawn or clamp to 1 ms and kill the command at once.
25
- const MAX_CAP_SECONDS = 2_147_483
33
+ /**
34
+ * Holds the longest cap in whole seconds.
35
+ *
36
+ * @remarks
37
+ * The longest wait a Node timer holds is 2147483647 ms; floored to whole seconds, a longer cap would
38
+ * throw from `AbortSignal.timeout` after the spawn or clamp to 1 ms and kill the command at once.
39
+ */
40
+ export const MAX_CAP_SECONDS = 2_147_483
26
41
 
27
- // `AbortSignal.timeout` throws on a fractional delay, which `setTimeout` truncates and raises to 1 ms
28
- // from below, so the cap takes that form before the command starts.
29
- function computeCapMilliseconds(cap: number): number {
42
+ /** Lists the flags that take a value. */
43
+ export const VALUE_FLAGS: readonly string[] = ['--journal', '--errors', '--cap']
44
+
45
+ /** Lists every flag the launcher reads before the separator, value flags and switches alike. */
46
+ export const FLAGS: readonly string[] = [...VALUE_FLAGS, '--status']
47
+
48
+ /** Describes how the command ended: its code, its signal, or the reason it could not start. */
49
+ export interface Exit {
50
+ readonly code: number | null
51
+ readonly signal: NodeJS.Signals | null
52
+ readonly error: string | undefined
53
+ }
54
+
55
+ /**
56
+ * Reads the value that follows a flag.
57
+ *
58
+ * @remarks
59
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
60
+ * and the `--flag=value` form is not read.
61
+ *
62
+ * @param argv - The arguments after the script path.
63
+ * @param name - The flag, such as `--journal`.
64
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
65
+ * another flag.
66
+ */
67
+ export function readOption(argv: readonly string[], name: string): string | undefined {
68
+ const index = argv.indexOf(name)
69
+ const value = index === -1 ? undefined : argv[index + 1]
70
+ return value === undefined || value.startsWith('--') ? undefined : value
71
+ }
72
+
73
+ /**
74
+ * Names the value flags a caller gave with no value.
75
+ *
76
+ * @param argv - The arguments after the script path.
77
+ * @param names - The flags that take a value.
78
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
79
+ */
80
+ export function readMissingFlags(
81
+ argv: readonly string[],
82
+ names: readonly string[],
83
+ ): readonly string[] {
84
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
85
+ }
86
+
87
+ /**
88
+ * Names the flags a caller gave that the script does not read.
89
+ *
90
+ * @remarks
91
+ * A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
92
+ * scan, so the command after it keeps every argument.
93
+ *
94
+ * @param argv - The arguments after the script path.
95
+ * @param flags - Every flag the script reads, value flags and switches alike.
96
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
97
+ */
98
+ export function readUnknownFlags(
99
+ argv: readonly string[],
100
+ flags: readonly string[],
101
+ ): readonly string[] {
102
+ const end = argv.indexOf('--')
103
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
104
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Computes the cap in whole milliseconds that the timer takes.
110
+ *
111
+ * @remarks
112
+ * `AbortSignal.timeout` throws on a fractional delay, which `setTimeout` truncates and raises to 1 ms
113
+ * from below, so the cap takes that form before the command starts.
114
+ *
115
+ * @param cap - The cap in seconds.
116
+ * @returns The whole milliseconds, at least 1.
117
+ */
118
+ export function computeCapMilliseconds(cap: number): number {
30
119
  return Math.max(1, Math.trunc(cap * 1000))
31
120
  }
32
121
 
33
- function readStatus(): string {
34
- const result = spawnSync('git', ['status', '--porcelain'], { encoding: 'utf8' })
122
+ /**
123
+ * Reads the working tree's `git status --porcelain` output.
124
+ *
125
+ * @param cwd - The directory to read the status in. Default: the current directory.
126
+ * @returns The porcelain text, or a `git status failed:` line carrying git's own message.
127
+ */
128
+ export function readGitStatus(cwd?: string): string {
129
+ const result = spawnSync('git', ['status', '--porcelain'], {
130
+ encoding: 'utf8',
131
+ ...(cwd === undefined ? {} : { cwd }),
132
+ })
35
133
  return result.status === 0 ? result.stdout : `git status failed: ${result.stderr}`
36
134
  }
37
135
 
38
- function killTree(pid: number): void {
136
+ /**
137
+ * Kills a process and every process it started, at once and without a chance to clean up.
138
+ *
139
+ * @param pid - The process to kill. A process that is already gone is not an error.
140
+ */
141
+ export function forceKillTree(pid: number): void {
39
142
  if (process.platform === 'win32') {
40
143
  spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
41
144
  return
@@ -45,18 +148,39 @@ function killTree(pid: number): void {
45
148
  } catch {}
46
149
  }
47
150
 
48
- interface Exit {
49
- readonly code: number | null
50
- readonly signal: NodeJS.Signals | null
51
- readonly error: string | undefined
151
+ /**
152
+ * Ends a command that is still running and aborts the controller to say the cap ended it.
153
+ *
154
+ * @remarks
155
+ * The cap's timer cannot be cleared, so it can elapse after the command exited on its own. The
156
+ * controller aborts only when the command is still running at that moment, which is how the run
157
+ * records that the cap, and not the command, ended it; a reused pid is never this command's.
158
+ *
159
+ * @param child - The running command.
160
+ * @param ended - The controller that aborts when the cap ended the command.
161
+ */
162
+ export function endRunning(child: ChildProcess, ended: AbortController): void {
163
+ if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
164
+ ended.abort()
165
+ forceKillTree(child.pid)
166
+ }
52
167
  }
53
168
 
54
- function startCommand(
169
+ /**
170
+ * Starts the command with its output sent to the journal and errors files.
171
+ *
172
+ * @param command - The executable.
173
+ * @param args - The command's arguments, passed verbatim.
174
+ * @param journal - The open journal file descriptor that receives stdout.
175
+ * @param errors - The open errors file descriptor that receives stderr.
176
+ * @returns The child process, or the failure when the spawn throws.
177
+ */
178
+ export function startCommand(
55
179
  command: string,
56
180
  args: readonly string[],
57
181
  journal: number,
58
182
  errors: number,
59
- ): ReturnType<typeof spawn> | Exit {
183
+ ): ChildProcess | Exit {
60
184
  try {
61
185
  return spawn(command, args, { stdio: ['ignore', journal, errors], windowsHide: true })
62
186
  } catch (error) {
@@ -68,17 +192,36 @@ function startCommand(
68
192
  }
69
193
  }
70
194
 
71
- function waitForExit(child: ReturnType<typeof spawn>): Promise<Exit> {
195
+ /**
196
+ * Waits for a started command to close.
197
+ *
198
+ * @param child - The started command.
199
+ * @returns How it ended: its code and signal, or the error that stopped it.
200
+ */
201
+ export function waitForExit(child: ChildProcess): Promise<Exit> {
72
202
  return new Promise((resolve) => {
73
203
  child.on('error', (error) => resolve({ code: null, signal: null, error: error.message }))
74
204
  child.on('close', (code, signal) => resolve({ code, signal, error: undefined }))
75
205
  })
76
206
  }
77
207
 
78
- async function main(argv: readonly string[]): Promise<number> {
208
+ /**
209
+ * Runs the launcher on its arguments.
210
+ *
211
+ * @param argv - The arguments after the script path.
212
+ * @returns The exit code: the command's, 124 when capped, 127 when it could not start, 64 on usage.
213
+ */
214
+ export async function main(argv: readonly string[]): Promise<number> {
79
215
  const separator = argv.indexOf('--')
80
216
  // The launcher's own flags end at the separator; everything after it belongs to the command.
81
217
  const own = separator === -1 ? argv : argv.slice(0, separator)
218
+ const unknown = readUnknownFlags(argv, FLAGS)
219
+ if (unknown.length > 0) {
220
+ console.error(
221
+ `launch: ${unknown.join(', ')} is not a flag this launcher reads before --; flags: ${FLAGS.join(' ')}`,
222
+ )
223
+ return 64
224
+ }
82
225
  const journal = readOption(own, '--journal')
83
226
  const errors = readOption(own, '--errors')
84
227
  const cap =
@@ -106,7 +249,7 @@ async function main(argv: readonly string[]): Promise<number> {
106
249
  const capMilliseconds = cap > 0 ? computeCapMilliseconds(cap) : undefined
107
250
  mkdirSync(dirname(journal), { recursive: true })
108
251
  mkdirSync(dirname(errors), { recursive: true })
109
- if (status) writeFileSync(`${journal}.status-before.txt`, readStatus())
252
+ if (status) writeFileSync(`${journal}.status-before.txt`, readGitStatus())
110
253
  const journalFd = openSync(journal, 'w')
111
254
  const errorsFd = openSync(errors, 'w')
112
255
  rmSync(`${journal}.pid`, { force: true })
@@ -117,9 +260,9 @@ async function main(argv: readonly string[]): Promise<number> {
117
260
  writeFileSync(`${journal}.pid`, `${child.pid}\n`)
118
261
  console.log(JSON.stringify({ pid: child.pid, journal, errors }))
119
262
  }
120
- // The signal aborts once the cap elapses, so whether it aborted is whether the cap ended the run.
121
- // The timer cannot be cleared, so a cap that elapses after the child exited kills nothing: a
122
- // reused pid is not this command's.
263
+ // The ended controller aborts only when the cap found the command still running, so whether it
264
+ // aborted is whether the cap ended the run.
265
+ const ended = new AbortController()
123
266
  const deadline =
124
267
  child === undefined || capMilliseconds === undefined
125
268
  ? undefined
@@ -127,14 +270,12 @@ async function main(argv: readonly string[]): Promise<number> {
127
270
  deadline?.addEventListener(
128
271
  'abort',
129
272
  () => {
130
- if (child?.pid !== undefined && child.exitCode === null && child.signalCode === null) {
131
- killTree(child.pid)
132
- }
273
+ if (child !== undefined) endRunning(child, ended)
133
274
  },
134
275
  { once: true },
135
276
  )
136
277
  const exit = 'error' in launched ? launched : await waitForExit(launched)
137
- const capped = deadline?.aborted === true
278
+ const capped = ended.signal.aborted
138
279
  closeSync(journalFd)
139
280
  closeSync(errorsFd)
140
281
  const durationMs = Date.now() - started
@@ -142,7 +283,7 @@ async function main(argv: readonly string[]): Promise<number> {
142
283
  errors,
143
284
  `${exit.error === undefined ? '' : `launch: the command could not start: ${exit.error}\n`}\nexit=${exit.error === undefined ? (exit.code ?? 'null') : '127'} signal=${exit.signal ?? 'none'} capped=${capped} duration_ms=${durationMs}\n`,
144
285
  )
145
- if (status) writeFileSync(`${journal}.status-after.txt`, readStatus())
286
+ if (status) writeFileSync(`${journal}.status-after.txt`, readGitStatus())
146
287
  console.log(
147
288
  JSON.stringify({
148
289
  pid: child?.pid,
@@ -159,6 +300,8 @@ async function main(argv: readonly string[]): Promise<number> {
159
300
  return capped ? 124 : (exit.code ?? 1)
160
301
  }
161
302
 
162
- main(process.argv.slice(2)).then((code) => {
163
- process.exitCode = code
164
- })
303
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
304
+ main(process.argv.slice(2)).then((code) => {
305
+ process.exitCode = code
306
+ })
307
+ }