@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
@@ -8,13 +8,15 @@
8
8
  // edited. The second form reads every backticked token carrying a slash (a backslash counts and is
9
9
  // read as a slash), strips a `:line` or
10
10
  // `:line-line` suffix, resolves it from the working directory, and prints the ones that do not
11
- // exist; a bare filename is outside the check. Exit 3 when any is missing, 64 on usage.
12
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
+ // exist; a bare filename is outside the check. The two forms are two modes; name one. The script
12
+ // refuses a flag it does not read, the `--flag=value` form included. Exit 3 when any is missing, 64
13
+ // on usage.
14
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
13
15
  import { dirname, join, resolve } from 'node:path'
14
16
  import { fileURLToPath } from 'node:url'
15
- import { readMissingFlags, readOption } from './helpers.ts'
16
17
 
17
- const TEMPLATE = resolve(
18
+ /** Names the brief template, found from this script's own location. */
19
+ export const TEMPLATE = resolve(
18
20
  dirname(fileURLToPath(import.meta.url)),
19
21
  '..',
20
22
  '..',
@@ -22,14 +24,85 @@ const TEMPLATE = resolve(
22
24
  'templates',
23
25
  'brief.md',
24
26
  )
25
- const LANES: Readonly<Record<string, string>> = Object.freeze({
27
+
28
+ /** Maps each lane name to the directory its briefs are written under. */
29
+ export const LANES: Readonly<Record<string, string>> = Object.freeze({
26
30
  units: 'tmp/units',
27
31
  cursor: 'tmp/cursor',
28
32
  codex: 'tmp/codex',
29
33
  claude: 'tmp/claude',
30
34
  })
31
35
 
32
- function readsAsPath(token: string): boolean {
36
+ /** Lists the flags that take a value. */
37
+ export const VALUE_FLAGS: readonly string[] = ['--unit', '--lane', '--subject', '--check']
38
+
39
+ /** Lists every flag the script reads, value flags and switches alike. */
40
+ export const FLAGS: readonly string[] = VALUE_FLAGS
41
+
42
+ /** Holds the usage line printed when a call names no mode. */
43
+ export const USAGE =
44
+ 'usage: brief.ts --unit UNIT --lane units|cursor|codex|claude [--subject TEXT] | --check PATH'
45
+
46
+ /**
47
+ * Reads the value that follows a flag.
48
+ *
49
+ * @remarks
50
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
51
+ * and the `--flag=value` form is not read.
52
+ *
53
+ * @param argv - The arguments after the script path.
54
+ * @param name - The flag, such as `--unit`.
55
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
56
+ * another flag.
57
+ */
58
+ export function readOption(argv: readonly string[], name: string): string | undefined {
59
+ const index = argv.indexOf(name)
60
+ const value = index === -1 ? undefined : argv[index + 1]
61
+ return value === undefined || value.startsWith('--') ? undefined : value
62
+ }
63
+
64
+ /**
65
+ * Names the value flags a caller gave with no value.
66
+ *
67
+ * @param argv - The arguments after the script path.
68
+ * @param names - The flags that take a value.
69
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
70
+ */
71
+ export function readMissingFlags(
72
+ argv: readonly string[],
73
+ names: readonly string[],
74
+ ): readonly string[] {
75
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
76
+ }
77
+
78
+ /**
79
+ * Names the flags a caller gave that the script does not read.
80
+ *
81
+ * @remarks
82
+ * A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
83
+ * scan.
84
+ *
85
+ * @param argv - The arguments after the script path.
86
+ * @param flags - Every flag the script reads, value flags and switches alike.
87
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
88
+ */
89
+ export function readUnknownFlags(
90
+ argv: readonly string[],
91
+ flags: readonly string[],
92
+ ): readonly string[] {
93
+ const end = argv.indexOf('--')
94
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
95
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Checks whether a backticked token reads as a repository path.
101
+ *
102
+ * @param token - The text between two backticks, with its line suffix already removed.
103
+ * @returns True if the token carries a slash and no URL, placeholder, glob, or flag form; false otherwise.
104
+ */
105
+ export function readsAsPath(token: string): boolean {
33
106
  if (token.includes('://') || /[<>*{}\s]/u.test(token)) return false
34
107
  if (/[A-Z]{2,}_[A-Z]/u.test(token)) return false
35
108
  if (token.startsWith('@') || token.startsWith('-') || token.startsWith('$')) return false
@@ -37,7 +110,13 @@ function readsAsPath(token: string): boolean {
37
110
  return token.includes('/')
38
111
  }
39
112
 
40
- function extractPaths(content: string): readonly string[] {
113
+ /**
114
+ * Extracts the slash-bearing paths a brief names.
115
+ *
116
+ * @param content - The brief text.
117
+ * @returns Each distinct path in sorted order, a backslash read as a slash and a line suffix removed.
118
+ */
119
+ export function extractPaths(content: string): readonly string[] {
41
120
  const found = new Set(
42
121
  [...content.matchAll(/`([^`\n]+)`/gu)]
43
122
  .map((match) => (match[1] ?? '').replace(/\\/gu, '/').replace(/:\d+(?:[-–]\d+)?$/u, ''))
@@ -46,12 +125,26 @@ function extractPaths(content: string): readonly string[] {
46
125
  return [...found].sort()
47
126
  }
48
127
 
49
- function resolveBriefPath(directory: string, unit: string, count = 1): string {
128
+ /**
129
+ * Resolves the first brief path for a unit that no file occupies.
130
+ *
131
+ * @param directory - The lane directory.
132
+ * @param unit - The unit name.
133
+ * @param count - The brief number to try first. Default: 1.
134
+ * @returns `<unit>-brief.md` for the first brief and `<unit>-brief-<n>.md` for a successor.
135
+ */
136
+ export function resolveBriefPath(directory: string, unit: string, count = 1): string {
50
137
  const path = join(directory, count === 1 ? `${unit}-brief.md` : `${unit}-brief-${count}.md`)
51
138
  return existsSync(path) ? resolveBriefPath(directory, unit, count + 1) : path
52
139
  }
53
140
 
54
- function writeBrief(argv: readonly string[]): number {
141
+ /**
142
+ * Writes a brief from the template.
143
+ *
144
+ * @param argv - The arguments after the script path.
145
+ * @returns The exit code: 0 on success, 64 on usage or a missing template.
146
+ */
147
+ export function writeBrief(argv: readonly string[]): number {
55
148
  const unit = readOption(argv, '--unit')
56
149
  const lane = readOption(argv, '--lane')
57
150
  const directory = lane === undefined ? undefined : LANES[lane]
@@ -74,7 +167,13 @@ function writeBrief(argv: readonly string[]): number {
74
167
  return 0
75
168
  }
76
169
 
77
- function checkBrief(path: string): number {
170
+ /**
171
+ * Checks that every path a brief names exists.
172
+ *
173
+ * @param path - The brief file.
174
+ * @returns The exit code: 0 when every path exists, 3 when one is missing, 64 when the brief is not a file.
175
+ */
176
+ export function checkBrief(path: string): number {
78
177
  if (!existsSync(path) || !statSync(path).isFile()) {
79
178
  console.error(`brief: ${path} is not a file`)
80
179
  return 64
@@ -85,8 +184,19 @@ function checkBrief(path: string): number {
85
184
  return missing.length === 0 ? 0 : 3
86
185
  }
87
186
 
88
- function main(argv: readonly string[]): number {
89
- const missing = readMissingFlags(argv, ['--unit', '--lane', '--subject', '--check'])
187
+ /**
188
+ * Runs the script on its arguments.
189
+ *
190
+ * @param argv - The arguments after the script path.
191
+ * @returns The exit code: 0 on success, 3 when a named path is missing, 64 on usage.
192
+ */
193
+ export function main(argv: readonly string[]): number {
194
+ const unknown = readUnknownFlags(argv, FLAGS)
195
+ if (unknown.length > 0) {
196
+ console.error(`brief: ${unknown.join(', ')} is not a flag this script reads; ${USAGE}`)
197
+ return 64
198
+ }
199
+ const missing = readMissingFlags(argv, VALUE_FLAGS)
90
200
  if (missing.length > 0) {
91
201
  console.error(`brief: ${missing.join(', ')} given with no value`)
92
202
  return 64
@@ -98,10 +208,10 @@ function main(argv: readonly string[]): number {
98
208
  }
99
209
  if (check !== undefined) return checkBrief(check)
100
210
  if (argv.includes('--unit')) return writeBrief(argv)
101
- console.error(
102
- 'usage: brief.ts --unit UNIT --lane units|cursor|codex|claude [--subject TEXT] | --check PATH',
103
- )
211
+ console.error(USAGE)
104
212
  return 64
105
213
  }
106
214
 
107
- process.exitCode = main(process.argv.slice(2))
215
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
216
+ process.exitCode = main(process.argv.slice(2))
217
+ }
@@ -0,0 +1,323 @@
1
+ // Run a plan of commands in order and journal each one. Run from the checkout root:
2
+ // node .agents/skills/orkestrel-dispatch/scripts/chain.ts --plan FILE.json --journal DIR
3
+ // The plan is a JSON array of steps, each `{ "name": "...", "command": "...", "args": ["..."] }`
4
+ // with an optional `"cwd"` (a directory, relative to the working directory) and `"env"` (a record
5
+ // of strings laid over this process's environment). A step name is letters, digits, dots,
6
+ // underscores, and hyphens, opens with a letter or digit, and is unique in the plan. Each step runs
7
+ // with no shell and closed stdin, in plan order, with its standard output and standard error
8
+ // written together to `DIR/<name>.txt`; the script prints one `name exit=N durationMs=N` line per
9
+ // step and stops at the first step that does not exit 0. Because no shell runs, a command that is a
10
+ // Windows `.cmd` shim, such as `npm`, starts only through the interpreter it wraps. A step that
11
+ // must not stop the chain belongs in a second plan; launch the chain itself through `launch.ts`
12
+ // for a cap. The script refuses a flag it does not read, the `--flag=value` form included.
13
+ // Exit 0 when every step exited 0, the failing step's exit code when one did not, 2 when a step
14
+ // cannot start, 64 on usage.
15
+ import { spawnSync } from 'node:child_process'
16
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync } from 'node:fs'
17
+ import { join, resolve } from 'node:path'
18
+
19
+ /** Lists the flags that take a value. */
20
+ export const VALUE_FLAGS: readonly string[] = ['--plan', '--journal']
21
+
22
+ /** Lists every flag the script reads, value flags and switches alike. */
23
+ export const FLAGS: readonly string[] = VALUE_FLAGS
24
+
25
+ /** Holds the usage line printed when a call names no plan or no journal. */
26
+ export const USAGE = 'usage: chain.ts --plan FILE.json --journal DIR'
27
+
28
+ /** Matches a step name that is safe as a journal file name. */
29
+ export const STEP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
30
+
31
+ /** Describes one step of a plan. */
32
+ export interface Step {
33
+ readonly name: string
34
+ readonly command: string
35
+ readonly args: readonly string[]
36
+ readonly cwd: string | undefined
37
+ readonly env: Readonly<Record<string, string>>
38
+ }
39
+
40
+ /** Describes a plan read from a file: its steps, or every problem that stopped it. */
41
+ export interface Plan {
42
+ readonly steps: readonly Step[]
43
+ readonly problems: readonly string[]
44
+ }
45
+
46
+ /** Describes one finished step: how it ended and how long it took. */
47
+ export interface Finish {
48
+ readonly exit: number | undefined
49
+ readonly error: string | undefined
50
+ readonly durationMs: number
51
+ }
52
+
53
+ /**
54
+ * Reads the value that follows a flag.
55
+ *
56
+ * @remarks
57
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
58
+ * and the `--flag=value` form is not read.
59
+ *
60
+ * @param argv - The arguments after the script path.
61
+ * @param name - The flag, such as `--plan`.
62
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
63
+ * another flag.
64
+ */
65
+ export function readOption(argv: readonly string[], name: string): string | undefined {
66
+ const index = argv.indexOf(name)
67
+ const value = index === -1 ? undefined : argv[index + 1]
68
+ return value === undefined || value.startsWith('--') ? undefined : value
69
+ }
70
+
71
+ /**
72
+ * Names the value flags a caller gave with no value.
73
+ *
74
+ * @param argv - The arguments after the script path.
75
+ * @param names - The flags that take a value.
76
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
77
+ */
78
+ export function readMissingFlags(
79
+ argv: readonly string[],
80
+ names: readonly string[],
81
+ ): readonly string[] {
82
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
83
+ }
84
+
85
+ /**
86
+ * Names the flags a caller gave that the script does not read.
87
+ *
88
+ * @remarks
89
+ * A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
90
+ * scan.
91
+ *
92
+ * @param argv - The arguments after the script path.
93
+ * @param flags - Every flag the script reads, value flags and switches alike.
94
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
95
+ */
96
+ export function readUnknownFlags(
97
+ argv: readonly string[],
98
+ flags: readonly string[],
99
+ ): readonly string[] {
100
+ const end = argv.indexOf('--')
101
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
102
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Checks whether a value is a non-null, non-array object.
108
+ *
109
+ * @param value - The value to inspect.
110
+ * @returns True if the value is an object other than null or an array; false otherwise.
111
+ */
112
+ export function isRecord(value: unknown): value is Record<string, unknown> {
113
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
114
+ }
115
+
116
+ /**
117
+ * Checks whether a value is an array of strings.
118
+ *
119
+ * @param value - The value to inspect.
120
+ * @returns True if the value is an array whose every member is a string; false otherwise.
121
+ */
122
+ export function isStringArray(value: unknown): value is readonly string[] {
123
+ return Array.isArray(value) && value.every((member) => typeof member === 'string')
124
+ }
125
+
126
+ /**
127
+ * Checks whether a value is a record whose every member is a string.
128
+ *
129
+ * @param value - The value to inspect.
130
+ * @returns True if the value is a record with only string members; false otherwise.
131
+ */
132
+ export function isStringRecord(value: unknown): value is Record<string, string> {
133
+ return isRecord(value) && Object.values(value).every((member) => typeof member === 'string')
134
+ }
135
+
136
+ /**
137
+ * Quotes an untrusted text for a diagnostic, JSON-encoded and cut to a bounded length.
138
+ *
139
+ * @param text - The text to quote.
140
+ * @returns The JSON string literal of the text, ending in an ellipsis inside the quotes when cut.
141
+ */
142
+ export function quote(text: string): string {
143
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text)
144
+ }
145
+
146
+ /**
147
+ * Lists what is wrong with one plan entry.
148
+ *
149
+ * @param entry - The parsed JSON value at one position of the plan.
150
+ * @param position - The entry's zero-based position, named in each problem.
151
+ * @returns Each problem in the entry, empty when the entry is a step.
152
+ */
153
+ export function readStepProblems(entry: unknown, position: number): readonly string[] {
154
+ const where = `step ${String(position + 1)}`
155
+ if (!isRecord(entry)) return [`${where} is not an object`]
156
+ const known = ['name', 'command', 'args', 'cwd', 'env']
157
+ const unexpected = Object.keys(entry).filter((key) => !known.includes(key))
158
+ return [
159
+ ...(typeof entry.name === 'string' && STEP_NAME.test(entry.name)
160
+ ? []
161
+ : [
162
+ `${where} "name" must be letters, digits, dots, underscores, and hyphens, opening with a letter or digit`,
163
+ ]),
164
+ ...(typeof entry.command === 'string' && entry.command !== ''
165
+ ? []
166
+ : [`${where} "command" must be a non-empty string`]),
167
+ ...(isStringArray(entry.args) ? [] : [`${where} "args" must be an array of strings`]),
168
+ ...(entry.cwd === undefined || (typeof entry.cwd === 'string' && entry.cwd !== '')
169
+ ? []
170
+ : [`${where} "cwd" must be a non-empty string when present`]),
171
+ ...(entry.env === undefined || isStringRecord(entry.env)
172
+ ? []
173
+ : [`${where} "env" must be a record of strings when present`]),
174
+ ...unexpected.map((key) => `${where} carries the unexpected key ${quote(key)}`),
175
+ ]
176
+ }
177
+
178
+ /**
179
+ * Reads a plan from its JSON text.
180
+ *
181
+ * @param text - The plan file's text.
182
+ * @returns The steps in order when the plan is valid, and every problem otherwise: not JSON, not an
183
+ * array, empty, a malformed step, or a name used twice.
184
+ */
185
+ export function parsePlan(text: string): Plan {
186
+ const parsed = parseJson(text)
187
+ if (!Array.isArray(parsed))
188
+ return { steps: [], problems: ['the plan is not a JSON array of steps'] }
189
+ if (parsed.length === 0) return { steps: [], problems: ['the plan holds no step'] }
190
+ const problems = parsed.flatMap((entry, position) => readStepProblems(entry, position))
191
+ const names = parsed.flatMap((entry) =>
192
+ isRecord(entry) && typeof entry.name === 'string' ? [entry.name] : [],
193
+ )
194
+ const repeated = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))]
195
+ const all = [
196
+ ...problems,
197
+ ...repeated.map((name) => `the name ${quote(name)} is used by more than one step`),
198
+ ]
199
+ if (all.length > 0) return { steps: [], problems: all }
200
+ return { steps: parsed.flatMap((entry) => readStep(entry)), problems: [] }
201
+ }
202
+
203
+ /**
204
+ * Parses a JSON text without throwing.
205
+ *
206
+ * @param text - The text to parse.
207
+ * @returns The parsed value, or undefined when the text is not JSON.
208
+ */
209
+ export function parseJson(text: string): unknown {
210
+ try {
211
+ return JSON.parse(text)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Reads a validated plan entry as a step.
219
+ *
220
+ * @param entry - A plan entry that `readStepProblems` found no problem in.
221
+ * @returns The step as a one-member list, or an empty list when the entry is not a valid step.
222
+ */
223
+ export function readStep(entry: unknown): readonly Step[] {
224
+ if (!isRecord(entry)) return []
225
+ const { name, command, args, cwd, env } = entry
226
+ if (typeof name !== 'string' || typeof command !== 'string' || !isStringArray(args)) return []
227
+ return [
228
+ {
229
+ name,
230
+ command,
231
+ args,
232
+ cwd: typeof cwd === 'string' ? cwd : undefined,
233
+ env: isStringRecord(env) ? env : {},
234
+ },
235
+ ]
236
+ }
237
+
238
+ /**
239
+ * Runs one step with no shell, its output and errors written together to a file.
240
+ *
241
+ * @param step - The step to run.
242
+ * @param output - The file that receives the step's standard output and standard error.
243
+ * @returns The exit code, or undefined with the reason when the command cannot start or a signal
244
+ * ended it; and the duration.
245
+ */
246
+ export function runStep(step: Step, output: string): Finish {
247
+ const started = Date.now()
248
+ const descriptor = openSync(output, 'w')
249
+ try {
250
+ const result = spawnSync(step.command, step.args, {
251
+ cwd: step.cwd === undefined ? undefined : resolve(step.cwd),
252
+ env: { ...process.env, ...step.env },
253
+ stdio: ['ignore', descriptor, descriptor],
254
+ windowsHide: true,
255
+ shell: false,
256
+ })
257
+ const error =
258
+ result.error?.message ?? (result.signal === null ? undefined : `ended by ${result.signal}`)
259
+ return {
260
+ exit: error === undefined ? (result.status ?? undefined) : undefined,
261
+ error,
262
+ durationMs: Date.now() - started,
263
+ }
264
+ } finally {
265
+ closeSync(descriptor)
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Formats the line printed for a finished step.
271
+ *
272
+ * @param step - The step.
273
+ * @param finish - How the step ended.
274
+ * @returns `name exit=N durationMs=N`, with `exit=none` and the reason when it never exited.
275
+ */
276
+ export function formatStep(step: Step, finish: Finish): string {
277
+ const exit = finish.exit === undefined ? 'none' : String(finish.exit)
278
+ const reason = finish.error === undefined ? '' : ` error=${quote(finish.error)}`
279
+ return `${step.name} exit=${exit} durationMs=${String(finish.durationMs)}${reason}`
280
+ }
281
+
282
+ /**
283
+ * Runs the script on its arguments.
284
+ *
285
+ * @param argv - The arguments after the script path.
286
+ * @returns The exit code: 0 when every step exited 0, the failing step's exit code, 2 when a step
287
+ * cannot start, 64 on usage.
288
+ */
289
+ export function main(argv: readonly string[]): number {
290
+ const unknown = readUnknownFlags(argv, FLAGS)
291
+ if (unknown.length > 0) {
292
+ console.error(`chain: ${unknown.join(', ')} is not a flag this script reads; ${USAGE}`)
293
+ return 64
294
+ }
295
+ const missing = readMissingFlags(argv, VALUE_FLAGS)
296
+ if (missing.length > 0) {
297
+ console.error(`chain: ${missing.join(', ')} given with no value`)
298
+ return 64
299
+ }
300
+ const path = readOption(argv, '--plan')
301
+ const journal = readOption(argv, '--journal')
302
+ if (path === undefined || journal === undefined || !existsSync(path)) {
303
+ console.error(USAGE)
304
+ return 64
305
+ }
306
+ const plan = parsePlan(readFileSync(path, 'utf8'))
307
+ if (plan.problems.length > 0) {
308
+ console.error(`chain: ${quote(path)} is not a valid plan: ${plan.problems.join('; ')}`)
309
+ return 64
310
+ }
311
+ mkdirSync(journal, { recursive: true })
312
+ for (const step of plan.steps) {
313
+ const finish = runStep(step, join(journal, `${step.name}.txt`))
314
+ console.log(formatStep(step, finish))
315
+ if (finish.exit === undefined) return 2
316
+ if (finish.exit !== 0) return finish.exit
317
+ }
318
+ return 0
319
+ }
320
+
321
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
322
+ process.exitCode = main(process.argv.slice(2))
323
+ }