@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.
- package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
- package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
- package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
- package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
- package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
- package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
- package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
- package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
- package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
- package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
- package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
- package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
- package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
- package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
- package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
- package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
- package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
- package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
- package/dist/agents/templates/brief.md +1 -1
- package/dist/bin/main.js +51 -46
- package/dist/bin/main.js.map +1 -1
- package/dist/host/AGENTS.md +23 -21
- package/dist/host/agents/orchestration.md +43 -18
- package/dist/host/agents/platforms/windows.md +3 -3
- package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
- package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
- package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
- package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
- package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
- package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
- package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
- package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
- package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
- package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
- package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
- package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
- package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
- package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
- package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
- package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
- package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
- package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
- package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
- package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
- package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
- package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
- package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
- package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
- package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
- package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
- package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
- package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
- package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
- package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
- package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
- package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
- package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
- package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
- package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
- package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
- package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
- package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
- package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
- package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
- package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
- package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
- package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
- package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
- package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
- package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
- package/dist/host/agents/templates/brief.md +1 -1
- package/dist/host/agents/transports/claude-cli.md +3 -3
- package/dist/host/agents/transports/codex.md +2 -7
- package/dist/host/agents/transports/cursor.md +2 -3
- package/dist/host/claude/AGENTS.md +2 -2
- package/dist/host/claude/agents/analyst.md +3 -3
- package/dist/host/claude/agents/astra.md +3 -3
- package/dist/host/claude/agents/builder.md +1 -1
- package/dist/host/claude/agents/checker.md +1 -1
- package/dist/host/claude/agents/distiller.md +1 -1
- package/dist/host/claude/agents/grok.md +1 -1
- package/dist/host/claude/agents/opus.md +2 -2
- package/dist/host/claude/agents/orkestrel.md +57 -90
- package/dist/host/claude/agents/researcher.md +1 -1
- package/dist/host/claude/agents/reviewer.md +4 -0
- package/dist/host/claude/agents/scout.md +1 -1
- package/dist/host/claude/agents/verifier.md +1 -1
- package/dist/host/claude/rules/application.md +14 -21
- package/dist/host/claude/rules/architecture.md +34 -44
- package/dist/host/claude/rules/browser.md +3 -4
- package/dist/host/claude/rules/campaign.md +2 -2
- package/dist/host/claude/rules/documentation.md +25 -12
- package/dist/host/claude/rules/names.md +9 -4
- package/dist/host/claude/rules/patterns.md +6 -7
- package/dist/host/claude/rules/portability.md +1 -1
- package/dist/host/claude/rules/quality.md +4 -5
- package/dist/host/claude/rules/styles.md +2 -2
- package/dist/host/claude/rules/tests.md +57 -114
- package/dist/host/claude/rules/typescript.md +7 -9
- package/dist/host/claude/rules/workspace.md +21 -19
- package/dist/host/claude/rules/writing.md +2 -5
- package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
- package/dist/host/codex/agents/analyst.toml +9 -3
- package/dist/host/codex/agents/astra.toml +3 -4
- package/dist/host/codex/agents/builder.toml +1 -1
- package/dist/host/codex/agents/opus.toml +3 -4
- package/dist/host/codex/agents/orkestrel.toml +1 -1
- package/dist/host/codex/agents/researcher.toml +2 -2
- package/dist/host/codex/agents/verifier.toml +1 -1
- package/dist/host/configs/helpers.ts +21 -1
- package/dist/host/configs/policy.ts +299 -15
- package/dist/host/dotfiles/oxlintrc.json +137 -3
- package/dist/host/guides/agent.md +111 -134
- package/dist/host/guides/capture.md +801 -0
- package/dist/host/guides/probe.md +27 -8
- package/dist/host/guides/scaffold.md +96 -18
- package/dist/host/guides/study.md +534 -440
- package/dist/host/guides/test.md +111 -44
- package/dist/host/manifest.json +160 -240
- package/dist/host/tests/config.test.ts +971 -187
- package/dist/host/tests/policy.test.ts +127 -1
- package/dist/host/tests/setupPolicy.ts +893 -33
- package/dist/src/core/index.cjs +214 -86
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +122 -38
- package/dist/src/core/index.d.ts +122 -38
- package/dist/src/core/index.js +209 -85
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +20 -27
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.js +21 -28
- package/dist/src/server/index.js.map +1 -1
- package/package.json +11 -11
- package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
- package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
- 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.
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
+
}
|