@agentsfleet/orly 0.10.14 → 0.12.0

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 (156) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +94 -28
  4. package/SOUL.md +13 -23
  5. package/audits/agents-md.md +67 -57
  6. package/audits/deinit-pairs.sh +13 -18
  7. package/audits/design-tokens.sh +9 -23
  8. package/audits/doc-read.sh +10 -33
  9. package/audits/doc-reads.ts +41 -0
  10. package/audits/error-codes.sh +18 -25
  11. package/audits/logging-rust.awk +145 -0
  12. package/audits/logging.sh +10 -166
  13. package/audits/msid-ui.sh +45 -18
  14. package/audits/rule-ledger-lib.sh +9 -6
  15. package/audits/rust-error.sh +20 -15
  16. package/audits/scope.sh +88 -0
  17. package/audits/spec-template.sh +2 -2
  18. package/audits/spec-template.ts +1 -1
  19. package/audits/sql-mod.sh +11 -14
  20. package/audits/ufs-strings.awk +164 -0
  21. package/audits/ufs.sh +10 -188
  22. package/core/operating-model.md +8 -8
  23. package/dispatch/edit_rules.md +8 -6
  24. package/dispatch/lib.sh +41 -26
  25. package/dispatch/lifecycle.md +12 -5
  26. package/dispatch/name_architecture.md +8 -3
  27. package/dispatch/verify.md +1 -1
  28. package/dispatch/write_any.md +10 -3
  29. package/dispatch/write_changelog.md +7 -4
  30. package/dispatch/write_go.md +1 -1
  31. package/dispatch/write_http.md +5 -7
  32. package/dispatch/write_python.md +1 -1
  33. package/dispatch/write_rust.md +17 -1
  34. package/dispatch/write_shell.md +1 -1
  35. package/dispatch/write_spec.md +3 -1
  36. package/dispatch/write_sql.md +28 -6
  37. package/dispatch/write_ts_adhere_bun.md +11 -0
  38. package/dispatch/write_zig.md +53 -21
  39. package/docs/CHANGELOG.md +10 -0
  40. package/docs/CHANGELOG_VOICE.md +11 -2
  41. package/docs/EXECUTE_DOC_READS.md +10 -10
  42. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  43. package/docs/JUDGMENTS.md +156 -0
  44. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  45. package/docs/LOGGING_STANDARD.md +39 -19
  46. package/docs/ORLY_ARCHITECTURE.md +77 -6
  47. package/docs/RELEASE_TEMPLATE.md +21 -5
  48. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  49. package/docs/RULE_ENFORCEMENT.md +6 -6
  50. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  51. package/docs/TEMPLATE.md +4 -4
  52. package/docs/VERIFY_TIERS.md +2 -2
  53. package/docs/greptile-learnings/RULES.md +81 -26
  54. package/llms.txt +46 -34
  55. package/package.json +6 -1
  56. package/packs/language/go/rules.md +1 -1
  57. package/packs/language/python/rules.md +1 -1
  58. package/packs/language/rust/rules.md +17 -1
  59. package/packs/language/shell/rules.md +1 -1
  60. package/registry.json +22 -7
  61. package/schemas/profile.schema.json +48 -5
  62. package/skills/orly-babysit-prs/SKILL.md +87 -34
  63. package/skills/orly-spec-new/SKILL.md +19 -6
  64. package/skills/orly-write-integration-test/SKILL.md +4 -4
  65. package/skills/orly-write-unit-test/SKILL.md +10 -1
  66. package/src/cli.test.ts +2 -2
  67. package/src/cli.ts +19 -75
  68. package/src/cli_help.ts +69 -0
  69. package/src/command_limits.ts +20 -0
  70. package/src/command_process.test.ts +109 -0
  71. package/src/command_process.ts +88 -0
  72. package/src/command_runner.test.ts +88 -0
  73. package/src/command_runner.ts +33 -0
  74. package/src/comprehension_cache.test.ts +123 -0
  75. package/src/config.test.ts +5 -4
  76. package/src/config.ts +26 -164
  77. package/src/config_discovery.ts +159 -0
  78. package/src/config_validation.test.ts +56 -0
  79. package/src/config_validation.ts +25 -0
  80. package/src/criteria.test.ts +1 -1
  81. package/src/criteria.ts +10 -26
  82. package/src/criteria_spec.ts +12 -20
  83. package/src/criteria_support.ts +2 -19
  84. package/src/doc_reads.test.ts +42 -0
  85. package/src/doc_rules.ts +2 -2
  86. package/src/evaluation_resources.test.ts +64 -0
  87. package/src/execution/config.ts +22 -0
  88. package/src/execution/plan.test.ts +65 -0
  89. package/src/execution/plan.ts +51 -0
  90. package/src/gates.ts +9 -6
  91. package/src/gates_spec.test.ts +52 -6
  92. package/src/gates_test_support.ts +9 -7
  93. package/src/install.test.ts +38 -222
  94. package/src/install.ts +117 -308
  95. package/src/install_loaders.test.ts +203 -0
  96. package/src/install_nested.test.ts +2 -2
  97. package/src/install_packs.test.ts +66 -6
  98. package/src/installation/hooks.ts +63 -0
  99. package/src/installation/lock.test.ts +97 -0
  100. package/src/installation/lock.ts +56 -0
  101. package/src/installation/migration.test.ts +212 -0
  102. package/src/installation/migration.ts +65 -0
  103. package/src/installation/plan.ts +96 -0
  104. package/src/installation/transaction.ts +126 -0
  105. package/src/installation_preservation.test.ts +48 -0
  106. package/src/judgments/advice.test.ts +38 -0
  107. package/src/judgments/advice.ts +32 -0
  108. package/src/judgments/command.integration.test.ts +47 -0
  109. package/src/judgments/command.ts +90 -0
  110. package/src/judgments/constants.ts +55 -0
  111. package/src/judgments/evidence.test.ts +48 -0
  112. package/src/judgments/evidence.ts +34 -0
  113. package/src/judgments/files.test.ts +62 -0
  114. package/src/judgments/files.ts +73 -0
  115. package/src/judgments/questions.test.ts +19 -0
  116. package/src/judgments/questions.ts +85 -0
  117. package/src/judgments/replay.test.ts +43 -0
  118. package/src/judgments/replay.ts +66 -0
  119. package/src/judgments/runner.integration.test.ts +165 -0
  120. package/src/judgments/runner.ts +97 -0
  121. package/src/judgments/scan.test.ts +19 -0
  122. package/src/judgments/scan.ts +40 -0
  123. package/src/judgments/syntax.test.ts +115 -0
  124. package/src/judgments/syntax.ts +71 -0
  125. package/src/judgments/syntax_worker.ts +70 -0
  126. package/src/judgments/test_support.ts +60 -0
  127. package/src/judgments/transport.integration.test.ts +63 -0
  128. package/src/judgments/transport.ts +35 -0
  129. package/src/judgments/types.test.ts +43 -0
  130. package/src/judgments/types.ts +66 -0
  131. package/src/judgments/wire.test.ts +48 -0
  132. package/src/judgments/wire.ts +40 -0
  133. package/src/loaders.test.ts +13 -16
  134. package/src/loaders.ts +28 -10
  135. package/src/model.test.ts +20 -3
  136. package/src/model.ts +7 -18
  137. package/src/pack_hygiene.test.ts +42 -1
  138. package/src/pack_hygiene.ts +18 -8
  139. package/src/references.ts +15 -7
  140. package/src/release_inventory.test.ts +80 -0
  141. package/src/render.test.ts +1 -1
  142. package/src/render.ts +3 -8
  143. package/src/required_scanner.test.ts +36 -0
  144. package/src/review_poll.test.ts +158 -0
  145. package/src/scanner_scope.test.ts +202 -0
  146. package/src/setup.test.ts +9 -6
  147. package/src/spec_baseline.test.ts +1 -1
  148. package/src/spec_completion.test.ts +74 -0
  149. package/src/spec_obligations.ts +64 -0
  150. package/src/spec_rehearsal.test.ts +182 -0
  151. package/src/spec_template_io.test.ts +11 -9
  152. package/src/surfaces.test.ts +2 -0
  153. package/src/surfaces.ts +1 -1
  154. package/src/validation.ts +7 -4
  155. package/src/verify.test.ts +18 -2
  156. package/src/verify.ts +7 -3
package/src/cli.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
- import { CONFIG_PATH, localSelection, managedDrift, readConfig, RepoConfig, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
5
+ import { printHelp } from "./cli_help";
6
+ import { selectPacks } from "./config_discovery";
7
+ import { CONFIG_PATH, managedDrift, readConfig, RepoConfig, staleVersion, writeConfig } from "./config";
6
8
  import { GateReport, isGateName, recordOverride, runGate, runGates } from "./gates";
9
+ import { readInstallConfig } from "./installation/migration";
7
10
  import { install, InstallResult } from "./install";
8
11
  import { isString, OrlyError, readJsonObject, RulesModel } from "./model";
9
12
  import { Renderer } from "./render";
@@ -33,9 +36,6 @@ const PR_GATE = "pr";
33
36
  const ENGINE_MARKER = "evals/install/run.sh";
34
37
  const VERIFY_COMMAND = "verify";
35
38
  const SKILL_EVENT_COMMAND = "skill-event";
36
- const TELEMETRY_CONFIG_KEY = "telemetry";
37
- const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
38
- const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
39
39
 
40
40
  const { root, arguments: commandArguments } = parseRoot(Bun.argv.slice(2));
41
41
  const startedAt = performance.now();
@@ -75,6 +75,16 @@ async function run(model: RulesModel, args: string[]): Promise<CliResult> {
75
75
  if (command === "init") return cliResult(await materialise(model, rest, true));
76
76
  if (command === "update") return cliResult(await materialise(model, rest, false));
77
77
  if (command === VERIFY_COMMAND) return cliResult(await verify(model, rest));
78
+ if (command === "judge") {
79
+ const { judgmentCommand } = await import("./judgments/command");
80
+ return cliResult(await judgmentCommand(rest));
81
+ }
82
+ if (command === "lifecycle") {
83
+ if (rest.some((argument) => argument !== JSON_FLAG)) throw new OrlyError("usage: orly lifecycle [--json]");
84
+ const { lifecyclePlan } = await import("./execution/plan");
85
+ console.log(JSON.stringify(lifecyclePlan(projectRoot()), undefined, JSON_INDENT));
86
+ return cliResult(0);
87
+ }
78
88
  if (command === "gate") return gate(model, rest);
79
89
  if (command === "override") return cliResult(override(rest));
80
90
  if (command === SKILL_EVENT_COMMAND) {
@@ -131,12 +141,12 @@ async function packageVersion(model: RulesModel): Promise<string> {
131
141
  return manifest.version;
132
142
  }
133
143
 
134
- // Both verbs materialise the same way: the repository's own `.oracle/orly.json`
144
+ // Both verbs materialise the same way: the repository's own `.orly/orly.json`
135
145
  // names its packs and commands, so neither asks the caller who this repo is.
136
146
  async function materialise(model: RulesModel, args: string[], isInit: boolean): Promise<number> {
137
147
  model.validate();
138
148
  const targetRoot = projectRoot();
139
- const existing = await readConfig(targetRoot);
149
+ const existing = await readInstallConfig(targetRoot);
140
150
  if (!isInit && !existing) throw new OrlyError(`no ${CONFIG_PATH} here — run \`orly init\` first`);
141
151
  const requested = optionalValues(args, WITH_FLAG);
142
152
  // --dry-run changes nothing, and recording an opt-in pack is a change. It
@@ -146,9 +156,9 @@ async function materialise(model: RulesModel, args: string[], isInit: boolean):
146
156
  // Otherwise the pack is a property of the repository, recorded before the
147
157
  // render rather than passed through it: the next `orly update` in a
148
158
  // teammate's clone selects the same set with no flag to remember.
149
- await recordOptIn(model, targetRoot, existing, requested);
150
159
  const result = await install(model, {
151
160
  targetRoot,
161
+ requestedPacks: requested,
152
162
  force: args.includes(FORCE_FLAG),
153
163
  installHooks: !args.includes(NO_HOOKS_FLAG),
154
164
  orlyVersion: await packageVersion(model),
@@ -210,7 +220,8 @@ async function doctorInstall(model: RulesModel): Promise<string[]> {
210
220
  // What init would write, without writing it. Replaces the `render` verb: the
211
221
  // preview belongs on the command you are about to run, not beside it.
212
222
  async function preview(model: RulesModel, targetRoot: string, requested: string[] = []): Promise<number> {
213
- const local = await localSelection(model, targetRoot);
223
+ const config = await readInstallConfig(targetRoot);
224
+ const local = { packs: selectPacks(model, targetRoot, config?.packs ?? [], new Set(config?.managed ?? [])), commands: config?.commands ?? {} };
214
225
  // selectPacks validates the requested names and unions them with what the
215
226
  // repository's own sources select — the same call the real run makes, minus
216
227
  // the write.
@@ -220,20 +231,6 @@ async function preview(model: RulesModel, targetRoot: string, requested: string[
220
231
  return 0;
221
232
  }
222
233
 
223
- // Opt-in packs never auto-select from file extensions, so naming one is the
224
- // only way it lands. Validated against the registry here, before anything is
225
- // recorded, so a typo names the available set instead of writing a config the
226
- // next command rejects.
227
- async function recordOptIn(model: RulesModel, targetRoot: string, existing: RepoConfig | undefined, requested: string[]): Promise<void> {
228
- if (requested.length === 0) return;
229
- const config = existing ?? await seedConfig(targetRoot);
230
- const packs = new Set(config.packs);
231
- for (const name of requested) packs.add(name);
232
- // selectPacks throws OrlyError on an unknown name, listing what is available.
233
- selectPacks(model, targetRoot, [...packs]);
234
- await writeConfig(targetRoot, { ...config, packs: [...packs].sort() });
235
- }
236
-
237
234
  async function verify(model: RulesModel, args: string[]): Promise<number> {
238
235
  requireEngineCheckout(model, VERIFY_COMMAND);
239
236
  const checks = await verifyRenders(model);
@@ -290,56 +287,3 @@ function optionalValues(args: string[], name: string): string[] {
290
287
  function cliResult(exitCode: number): CliResult {
291
288
  return { exitCode };
292
289
  }
293
-
294
- function printHelp(): void {
295
- console.log(`orly — prove the boundary; carry the rules
296
-
297
- Gates (read-only; no PR without every criterion green or a recorded override):
298
- orly gate [--accept-dirty] run work → verify → pr; stop at first red
299
- orly gate <work|verify|pr> run one gate
300
- work does this commit conform? the declared conform command; no git
301
- state, so a commit hook's own dirty tree never blocks it
302
- verify does the work hold up? configuration, docs language, and the
303
- non-test verify.* set; unfinished Sections may be pushed
304
- pr can this ship? branch, tree, pushed, every spec criterion, and
305
- every declared verify.* command
306
- orly override <CRITERION> --reason <REASON>
307
- empty commit with an Orly-Override trailer
308
-
309
- Install (the repository is the unit — no checkout of this package required):
310
- orly init [--force] [--no-hooks] [--with <PACK>] [--dry-run] [--json]
311
- materialise rules, gates, hooks, and a
312
- seeded .oracle/orly.json. A repository that
313
- already has an AGENTS.md keeps it: orly's
314
- rules land as AGENTS.orly.md, reached by a
315
- pointer block in the file you own. One
316
- import line lands per runtime that needs
317
- one (CLAUDE.md, opencode.json), so the
318
- rules load rather than wait to be noticed;
319
- a loader you wrote yourself is left alone.
320
- orly update [--force] [--with <PACK>] [--dry-run] [--json]
321
- re-materialise at the installed engine version
322
-
323
- --with <PACK> record an opt-in pack (repeatable) in
324
- .oracle/orly.json, so every clone selects it
325
- --dry-run show what would be written; change nothing
326
-
327
- orly doctor check this repository's installed rules
328
- against what orly would write today
329
- orly --version the installed package version
330
-
331
- Usage telemetry (off by default):
332
- Consent file: ~/.config/agentsfleet/orly/.orly.json
333
- State-root override: AGENTSFLEET_STATE_DIR
334
- Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
335
- names, outcome, failed criterion, duration, Orly version, operating system, architecture,
336
- invocation type, timestamps, and random event, session, and installation IDs.
337
- Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
338
- ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
339
- Orly never collects source, paths, repositories, branches, arguments, prompts,
340
- output, environment values, raw errors, or personal and account details.
341
-
342
- Ruleset authoring (an orly checkout only — refused from an installed package):
343
- orly verify the rules render the same twice, and the
344
- committed copy matches that render`);
345
- }
@@ -0,0 +1,69 @@
1
+ const TELEMETRY_CONFIG_KEY = "telemetry";
2
+ const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
3
+ const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
4
+
5
+ const HELP_TEXT = `orly — prove the boundary; carry the rules
6
+
7
+ Gates (read-only; no PR without every criterion green or a recorded override):
8
+ orly gate [--accept-dirty] run work → verify → pr; stop at first red
9
+ orly gate <work|verify|pr> run one gate
10
+ work does this commit conform? the declared conform command; no git
11
+ state, so a commit hook's own dirty tree never blocks it
12
+ verify does the work hold up? configuration, docs language, and the
13
+ non-test verify.* set; unfinished Sections may be pushed
14
+ pr can this ship? branch, tree, pushed, every spec criterion, and
15
+ every declared verify.* command
16
+ orly override <CRITERION> --reason <REASON>
17
+ empty commit with an Orly-Override trailer
18
+ Commands default to 30 minutes and 64 mebibytes of combined output each.
19
+ Set limits.<command>.timeout_ms and output_bytes in .orly/orly.json to override.
20
+ Exceeding either limit fails the check and prints the saved output path.
21
+
22
+ Advice (explicit; hooks stay offline):
23
+ orly judge <plan|verify|review|document> --input <MANIFEST>
24
+ exact replay of bounded Jev advice
25
+ orly judge --help input selectors, questions and refresh
26
+ orly lifecycle [--json] inspect stages, declared local checks and
27
+ resource ownership; remote adapter disabled
28
+
29
+ Install (the repository is the unit — no checkout of this package required):
30
+ orly init [--force] [--no-hooks] [--with <PACK>] [--dry-run] [--json]
31
+ materialise rules, gates, hooks, and a
32
+ seeded .orly/orly.json. A repository that
33
+ already has an AGENTS.md keeps it: orly's
34
+ rules land as .orly/AGENTS.md, reached by a
35
+ pointer block in the file you own. One
36
+ import line lands per runtime that needs
37
+ one (CLAUDE.md, opencode.json), so the
38
+ rules load rather than wait to be noticed;
39
+ a loader you wrote yourself is left alone.
40
+ orly update [--force] [--with <PACK>] [--dry-run] [--json]
41
+ re-materialise at the installed engine version
42
+
43
+ --with <PACK> record an opt-in pack (repeatable) in
44
+ .orly/orly.json, so every clone selects it
45
+ --dry-run show what would be written; change nothing
46
+
47
+ orly doctor check this repository's installed rules
48
+ against what orly would write today
49
+ orly --version the installed package version
50
+
51
+ Usage telemetry (off by default):
52
+ Consent file: ~/.config/agentsfleet/orly/.orly.json
53
+ State-root override: AGENTSFLEET_STATE_DIR
54
+ Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
55
+ names, outcome, failed criterion, duration, Orly version, operating system, architecture,
56
+ invocation type, timestamps, and random event, session, and installation IDs.
57
+ Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
58
+ ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
59
+ Orly never collects source, paths, repositories, branches, arguments, prompts,
60
+ output, environment values, raw errors, or personal and account details.
61
+
62
+ Ruleset authoring (an orly checkout only — refused from an installed package):
63
+ orly verify the rules render the same twice, and the
64
+ committed copy matches that render`;
65
+
66
+ export function printHelp(): void {
67
+ // logging: command help is the program interface.
68
+ console.log(HELP_TEXT);
69
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+
3
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1_000;
4
+ export const DEFAULT_COMMAND_OUTPUT_BYTES = 64 * 1024 * 1024;
5
+ export const SUPERVISOR_GRACE_MS = 5000;
6
+ const MAX_COMMAND_TIMEOUT_MS = 2 ** 31 - 1 - SUPERVISOR_GRACE_MS;
7
+ export const DEFAULT_COMMAND_LIMITS = Object.freeze({ timeout_ms: DEFAULT_COMMAND_TIMEOUT_MS, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
8
+ export const commandLimitsSchema = z.object({
9
+ timeout_ms: z.number().int().positive().max(MAX_COMMAND_TIMEOUT_MS).optional(),
10
+ output_bytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
11
+ }).strict();
12
+ export type CommandLimits = typeof DEFAULT_COMMAND_LIMITS;
13
+ export type LaneLimits = Record<string, z.infer<typeof commandLimitsSchema>>;
14
+
15
+ export function limitsFor(limits: LaneLimits | undefined, lane: string): CommandLimits {
16
+ return {
17
+ timeout_ms: limits?.[lane]?.timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS,
18
+ output_bytes: limits?.[lane]?.output_bytes ?? DEFAULT_COMMAND_OUTPUT_BYTES,
19
+ };
20
+ }
@@ -0,0 +1,109 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, ROOT, temporaryDirectory } from "./gates_test_support";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+
8
+ const PROCESS_SOURCE = "src/command_process.ts";
9
+ const RESULT_FILE = "result.json";
10
+ const ENCODING = "utf8";
11
+ const TEST_TIMEOUT_MS = 5_000;
12
+ const WORKLOAD_TIMEOUT_MS = 10_000;
13
+ const CHILD_START_POLL_MS = 10;
14
+ const CHILD_START_ATTEMPTS = 200;
15
+ const REPEATED_STOP_REFUSAL = `const initialKill = process.kill;
16
+ const stoppedGroups = new Set<number>();
17
+ process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
18
+ if (pid < 0 && signal === "SIGKILL") {
19
+ if (stoppedGroups.has(pid)) throw Object.assign(new Error("group already stopped"), { code: "EPERM" });
20
+ stoppedGroups.add(pid);
21
+ }
22
+ return initialKill(pid, signal);
23
+ }) as typeof process.kill;
24
+ `;
25
+
26
+ afterEach(cleanupTemporaryDirectories);
27
+
28
+ test("a failed pid receipt write stops the already started workload", () => {
29
+ const root = temporaryDirectory();
30
+ const pidFile = join(root, "owned.pid");
31
+ const script = join(root, "command_process.ts");
32
+ const source = readFileSync(join(ROOT, PROCESS_SOURCE), ENCODING);
33
+ const waitForChild = `for (let attempt = 0; attempt < ${CHILD_START_ATTEMPTS} && !await Bun.file(${JSON.stringify(pidFile)}).exists(); attempt++) await Bun.sleep(${CHILD_START_POLL_MS});`;
34
+ writeFileSync(script, source.replace("writeFileSync(join(request.receipt, COMMAND_FILES.pid),", `${waitForChild}\n writeFileSync(join(request.receipt, COMMAND_FILES.pid),`));
35
+ mkdirSync(join(root, "child.pid"));
36
+ const request = join(root, "request.json");
37
+ writeFileSync(request, JSON.stringify({ command: ["sh", "-c", `echo $$ > '${pidFile}'; exec sleep 10`], limits: { timeout_ms: WORKLOAD_TIMEOUT_MS, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
38
+ let pid: number | undefined;
39
+ try {
40
+ const supervisor = Bun.spawnSync([process.execPath, script, request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS });
41
+ expect(supervisor.exitCode).not.toBe(0);
42
+ expect(supervisor.stderr.toString()).toContain("EISDIR");
43
+ expect(existsSync(pidFile)).toBeTrue();
44
+ const workloadPid = Number(readFileSync(pidFile, ENCODING));
45
+ pid = workloadPid;
46
+ expect(() => process.kill(workloadPid, 0)).toThrow();
47
+ } finally {
48
+ if (pid) { try { process.kill(-pid, "SIGKILL"); } catch { /* owned group already stopped */ } }
49
+ }
50
+ });
51
+
52
+ test("a successful deadline stop is not repeated during supervisor cleanup", () => {
53
+ const root = temporaryDirectory();
54
+ const script = join(root, "command_process.ts");
55
+ writeFileSync(script, REPEATED_STOP_REFUSAL + readFileSync(join(ROOT, PROCESS_SOURCE), ENCODING));
56
+ const request = join(root, "request.json");
57
+ writeFileSync(request, JSON.stringify({ command: ["sh", "-c", "echo measured; sleep 3"], limits: { timeout_ms: 100, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
58
+ const child = Bun.spawnSync([process.execPath, script, request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS });
59
+ expect(child.exitCode, child.stderr.toString()).toBe(0);
60
+ expect(child.stderr.toString()).toBe("");
61
+ expect(JSON.parse(readFileSync(join(root, RESULT_FILE), ENCODING)).failure).toBe("deadline exceeded");
62
+ expect(readFileSync(join(root, "stdout.log"), ENCODING)).toBe("measured\n");
63
+ });
64
+
65
+ test("killing the gate owner stops its detached workload", async () => {
66
+ const root = temporaryDirectory();
67
+ const pidFile = join(root, "owned.pid");
68
+ const wrapper = join(root, "gate.ts");
69
+ writeFileSync(wrapper, `import { runCommand } from ${JSON.stringify(join(ROOT, "src/command_runner.ts"))}; runCommand(${JSON.stringify(root)}, ["sh", "-c", "echo $$ > owned.pid; sleep 10"], {timeout_ms: ${WORKLOAD_TIMEOUT_MS}, output_bytes: 4096});`);
70
+ const gate = Bun.spawn([process.execPath, wrapper], { cwd: root, env: { ...UNSCOPED_ENVIRONMENT, TMPDIR: root }, stdout: "pipe", stderr: "pipe" });
71
+ let pid: number | undefined;
72
+ try {
73
+ for (let attempt = 0; attempt < 200 && !existsSync(pidFile); attempt++) await Bun.sleep(10);
74
+ expect(existsSync(pidFile)).toBeTrue();
75
+ pid = Number(readFileSync(pidFile, ENCODING));
76
+ gate.kill("SIGKILL");
77
+ await gate.exited;
78
+ let alive = true;
79
+ for (let attempt = 0; attempt < 100 && alive; attempt++) {
80
+ await Bun.sleep(10);
81
+ try { process.kill(pid, 0); } catch { alive = false; }
82
+ }
83
+ expect(alive).toBeFalse();
84
+ } finally {
85
+ gate.kill("SIGKILL");
86
+ await gate.exited;
87
+ if (pid) { try { process.kill(-pid, "SIGKILL"); } catch { /* owned group already stopped */ } }
88
+ }
89
+ }, TEST_TIMEOUT_MS);
90
+
91
+ test.each(["SIGTERM", "SIGINT", "SIGHUP"] as const)("supervisor %s stops its owned workload", async (signal) => {
92
+ const root = temporaryDirectory();
93
+ const request = join(root, "request.json");
94
+ writeFileSync(request, JSON.stringify({ command: ["sh", "-c", "echo ready; sleep 10"], limits: { timeout_ms: WORKLOAD_TIMEOUT_MS, output_bytes: 4096 }, receipt: root, parent_pid: process.pid }));
95
+ const supervisor = Bun.spawn([process.execPath, join(ROOT, PROCESS_SOURCE), request], { env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe" });
96
+ try {
97
+ for (let attempt = 0; attempt < 200 && !existsSync(join(root, "child.pid")); attempt++) await Bun.sleep(10);
98
+ expect(existsSync(join(root, "child.pid"))).toBeTrue();
99
+ await Bun.sleep(20);
100
+ supervisor.kill(signal);
101
+ expect(await supervisor.exited).toBe(0);
102
+ expect(JSON.parse(readFileSync(join(root, RESULT_FILE), ENCODING)).failure).toBe(`supervisor received ${signal}`);
103
+ const pid = Number(readFileSync(join(root, "child.pid"), ENCODING));
104
+ expect(() => process.kill(pid, 0)).toThrow();
105
+ } finally {
106
+ supervisor.kill("SIGKILL");
107
+ await supervisor.exited;
108
+ }
109
+ }, TEST_TIMEOUT_MS);
@@ -0,0 +1,88 @@
1
+ import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { CommandLimits } from "./command_limits";
5
+
6
+ const PRIVATE_FILE_MODE = 0o600;
7
+ const TEXT_ENCODING = "utf8";
8
+ const KILL_SIGNAL = "SIGKILL";
9
+ const MISSING_PROCESS = "ESRCH";
10
+ const PIPE_OUTPUT = "pipe";
11
+ const OWNER_POLL_MS = 100;
12
+ const TERMINATION_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
13
+ export const COMMAND_FILES = {
14
+ result: "result.json", pid: "child.pid", stdout: "stdout.log", stderr: "stderr.log",
15
+ } as const;
16
+
17
+ type Request = { command: string[]; limits: CommandLimits; receipt: string; parent_pid: number };
18
+ export type CommandResult = { exit_code: number | null; elapsed_ms: number; output_bytes: number; failure?: string };
19
+
20
+ export function stopOwnedGroup(pid: number): void {
21
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("invalid owned process group");
22
+ try { process.kill(-pid, KILL_SIGNAL); }
23
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== MISSING_PROCESS) throw error; }
24
+ }
25
+
26
+ async function capture(stream: ReadableStream<Uint8Array>, file: string, onBytes: (size: number) => void): Promise<void> {
27
+ for await (const chunk of stream) {
28
+ appendFileSync(file, chunk);
29
+ onBytes(chunk.byteLength);
30
+ }
31
+ }
32
+
33
+ async function run(request: Request): Promise<CommandResult> {
34
+ const started = performance.now();
35
+ const stdout = join(request.receipt, COMMAND_FILES.stdout);
36
+ const stderr = join(request.receipt, COMMAND_FILES.stderr);
37
+ for (const file of [stdout, stderr]) writeFileSync(file, "", { mode: PRIVATE_FILE_MODE });
38
+ let child;
39
+ try {
40
+ child = Bun.spawn(request.command, { detached: true, stdin: "ignore", stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
41
+ } catch (error) {
42
+ return { exit_code: null, elapsed_ms: Math.ceil(performance.now() - started), output_bytes: 0, failure: `${request.command[0] ?? "command"} could not be run: ${error instanceof Error ? error.message : String(error)}` };
43
+ }
44
+ let stopped = false;
45
+ try {
46
+ writeFileSync(join(request.receipt, COMMAND_FILES.pid), String(child.pid), { mode: PRIVATE_FILE_MODE });
47
+ let outputBytes = 0;
48
+ let failure: string | undefined;
49
+ const stop = (reason: string) => {
50
+ failure ??= reason;
51
+ if (stopped) return;
52
+ stopOwnedGroup(child.pid);
53
+ stopped = true;
54
+ };
55
+ const timer = setTimeout(() => stop("deadline exceeded"), request.limits.timeout_ms);
56
+ const owner = setInterval(() => {
57
+ try { process.kill(request.parent_pid, 0); }
58
+ catch (error) { if ((error as NodeJS.ErrnoException).code === MISSING_PROCESS) stop("gate owner exited"); }
59
+ }, OWNER_POLL_MS);
60
+ const signals = TERMINATION_SIGNALS.map((signal) => {
61
+ const handler = () => stop(`supervisor received ${signal}`);
62
+ process.on(signal, handler);
63
+ return { signal, handler };
64
+ });
65
+ try {
66
+ const onBytes = (size: number) => {
67
+ outputBytes += size;
68
+ if (outputBytes > request.limits.output_bytes) stop("output limit exceeded");
69
+ };
70
+ await Promise.all([capture(child.stdout, stdout, onBytes), capture(child.stderr, stderr, onBytes), child.exited]);
71
+ if (child.signalCode) failure ??= `stopped by ${child.signalCode}`;
72
+ return { exit_code: child.exitCode, elapsed_ms: Math.ceil(performance.now() - started), output_bytes: outputBytes, ...(failure ? { failure } : {}) };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ clearInterval(owner);
76
+ for (const { signal, handler } of signals) process.off(signal, handler);
77
+ }
78
+ } finally {
79
+ if (!stopped) stopOwnedGroup(child.pid);
80
+ await child.exited;
81
+ }
82
+ }
83
+
84
+ if (import.meta.main) {
85
+ const request: Request = JSON.parse(readFileSync(Bun.argv[2]!, TEXT_ENCODING));
86
+ const result = await run(request);
87
+ writeFileSync(join(request.receipt, COMMAND_FILES.result), JSON.stringify(result), { mode: PRIVATE_FILE_MODE });
88
+ }
@@ -0,0 +1,88 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { runCommand } from "./command_runner";
7
+ import { criteriaFor } from "./criteria";
8
+ import { CONFIG_PATH } from "./config";
9
+ import { cleanupTemporaryDirectories, modelFor, newRepository } from "./gates_test_support";
10
+
11
+ const PRIVATE_DIRECTORY_MODE = 0o700;
12
+ const PRIVATE_FILE_MODE = 0o600;
13
+ const PERMISSION_MASK = 0o777;
14
+ const CHILD_FILE = "child.pid";
15
+ const UNIT_LANE = "verify.unit";
16
+ const roots: string[] = [];
17
+
18
+ afterEach(() => {
19
+ cleanupTemporaryDirectories();
20
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
21
+ });
22
+
23
+ function scratch(): string {
24
+ const root = mkdtempSync(join(tmpdir(), "orly-command-test-"));
25
+ roots.push(root);
26
+ return root;
27
+ }
28
+
29
+ function receipt(detail: string): string {
30
+ const path = detail.split("complete captured output: ")[1];
31
+ expect(path).toBeDefined();
32
+ roots.push(path!);
33
+ expect(statSync(path!).mode & PERMISSION_MASK).toBe(PRIVATE_DIRECTORY_MODE);
34
+ for (const file of ["stdout.log", "stderr.log"]) expect(statSync(join(path!, file)).mode & PERMISSION_MASK).toBe(PRIVATE_FILE_MODE);
35
+ return path!;
36
+ }
37
+
38
+ async function expectChildStopped(root: string): Promise<void> {
39
+ expect(existsSync(join(root, CHILD_FILE))).toBeTrue();
40
+ const pid = Number(readFileSync(join(root, CHILD_FILE), "utf8").trim());
41
+ for (let attempt = 0; attempt < 100; attempt++) {
42
+ try { process.kill(pid, 0); } catch { return; }
43
+ await Bun.sleep(10);
44
+ }
45
+ throw new Error(`owned child ${pid} survived the command limit`);
46
+ }
47
+
48
+ test("a deadline stops the owned child group and keeps captured output", async () => {
49
+ const root = scratch();
50
+ const result = runCommand(root, ["sh", "-c", `sleep 30 & echo $! > ${CHILD_FILE}; echo started; echo detail >&2; wait`], { timeout_ms: 300, output_bytes: 4096 });
51
+ expect(result.ok).toBeFalse();
52
+ expect(result.detail).toContain("deadline exceeded");
53
+ expect(result.detail).toContain("300 ms deadline");
54
+ const path = receipt(result.detail);
55
+ expect(readFileSync(join(path, "stdout.log"), "utf8")).toBe("started\n");
56
+ expect(readFileSync(join(path, "stderr.log"), "utf8")).toBe("detail\n");
57
+ await expectChildStopped(root);
58
+ });
59
+
60
+ test("the output budget covers both streams and stops the owned child group", async () => {
61
+ const root = scratch();
62
+ const chunk = "123456789012345678901234";
63
+ const started = performance.now();
64
+ const result = runCommand(root, ["sh", "-c", `sleep 30 & echo $! > ${CHILD_FILE}; printf ${chunk}; printf ${chunk} >&2; wait`], { timeout_ms: 3000, output_bytes: 32 });
65
+ expect(performance.now() - started).toBeLessThan(1500);
66
+ expect(result.ok).toBeFalse();
67
+ expect(result.detail).toContain("output limit exceeded");
68
+ expect(result.detail).toContain("48 bytes / 32 bytes");
69
+ const path = receipt(result.detail);
70
+ expect(readFileSync(join(path, "stdout.log"), "utf8")).toBe(chunk);
71
+ expect(readFileSync(join(path, "stderr.log"), "utf8")).toBe(chunk);
72
+ await expectChildStopped(root);
73
+ });
74
+
75
+ test("an explicit lane override applies through the public gate criteria", async () => {
76
+ const root = newRepository();
77
+ const model = await modelFor(root);
78
+ writeFileSync(join(root, CONFIG_PATH), JSON.stringify({
79
+ schema_version: 1, commands: { conform: [["true"]], [UNIT_LANE]: [["sh", "-c", "echo measured; sleep 3"]] },
80
+ limits: { [UNIT_LANE]: { timeout_ms: 200, output_bytes: 4096 } },
81
+ }));
82
+ const context = { root, model, acceptDirty: false };
83
+ const check = criteriaFor("pr", context).find((entry) => entry.name === `cmd.${UNIT_LANE}`)!;
84
+ const result = check.evaluate(context);
85
+ expect(result.ok).toBeFalse();
86
+ expect(result.detail).toContain("200 ms deadline");
87
+ expect(readFileSync(join(receipt(result.detail), "stdout.log"), "utf8")).toBe("measured\n");
88
+ });
@@ -0,0 +1,33 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { DEFAULT_COMMAND_LIMITS, SUPERVISOR_GRACE_MS, type CommandLimits } from "./command_limits";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+ import { COMMAND_FILES, stopOwnedGroup, type CommandResult } from "./command_process";
8
+ import type { Verdict } from "./criteria_support";
9
+
10
+ const PRIVATE_FILE_MODE = 0o600;
11
+ const TEXT_ENCODING = "utf8";
12
+ const PIPE_OUTPUT = "pipe";
13
+ const RECEIPT_PREFIX = "orly-command-";
14
+
15
+ export function runCommand(root: string, command: string[], limits: CommandLimits = DEFAULT_COMMAND_LIMITS): Verdict {
16
+ const receipt = mkdtempSync(join(tmpdir(), RECEIPT_PREFIX));
17
+ const request = join(receipt, "request.json");
18
+ writeFileSync(request, JSON.stringify({ command, limits, receipt, parent_pid: process.pid }), { mode: PRIVATE_FILE_MODE });
19
+ const supervisor = Bun.spawnSync([process.execPath, join(import.meta.dir, "command_process.ts"), request], {
20
+ cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT,
21
+ detached: true, timeout: limits.timeout_ms + SUPERVISOR_GRACE_MS, killSignal: "SIGKILL",
22
+ });
23
+ if (supervisor.exitCode !== 0) {
24
+ stopOwnedGroup(supervisor.pid);
25
+ try { stopOwnedGroup(Number(readFileSync(join(receipt, COMMAND_FILES.pid), TEXT_ENCODING))); } catch { /* child may not have started */ }
26
+ return { ok: false, detail: `command supervisor failed (${supervisor.signalCode ?? supervisor.exitCode}); complete captured output: ${receipt}; ${supervisor.stderr.toString().trim()}` };
27
+ }
28
+ const result: CommandResult = JSON.parse(readFileSync(join(receipt, COMMAND_FILES.result), TEXT_ENCODING));
29
+ if (result.failure) return { ok: false, detail: `${result.failure}; ${result.elapsed_ms} ms / ${limits.timeout_ms} ms deadline; ${result.output_bytes} bytes / ${limits.output_bytes} bytes output budget; complete captured output: ${receipt}` };
30
+ const output = [COMMAND_FILES.stdout, COMMAND_FILES.stderr].map((file) => readFileSync(join(receipt, file), TEXT_ENCODING)).filter(Boolean).join("\n").trim();
31
+ rmSync(receipt, { recursive: true, force: true });
32
+ return { ok: result.exit_code === 0, detail: output ? `exit ${result.exit_code}:\n${output}` : result.exit_code === 0 ? "exit 0" : `exit ${result.exit_code}: no output` };
33
+ }