@agentsfleet/orly 0.10.13 → 0.11.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 (155) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +65 -29
  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 +18 -6
  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_VOICE.md +11 -2
  40. package/docs/EXECUTE_DOC_READS.md +10 -10
  41. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  42. package/docs/JUDGMENTS.md +153 -0
  43. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  44. package/docs/LOGGING_STANDARD.md +39 -19
  45. package/docs/ORLY_ARCHITECTURE.md +77 -6
  46. package/docs/RELEASE_TEMPLATE.md +21 -5
  47. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  48. package/docs/RULE_ENFORCEMENT.md +6 -6
  49. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  50. package/docs/TEMPLATE.md +4 -4
  51. package/docs/VERIFY_TIERS.md +2 -2
  52. package/docs/greptile-learnings/RULES.md +81 -26
  53. package/llms.txt +37 -34
  54. package/package.json +6 -1
  55. package/packs/language/go/rules.md +1 -1
  56. package/packs/language/python/rules.md +1 -1
  57. package/packs/language/rust/rules.md +17 -1
  58. package/packs/language/shell/rules.md +1 -1
  59. package/registry.json +22 -7
  60. package/schemas/profile.schema.json +48 -5
  61. package/skills/orly-babysit-prs/SKILL.md +87 -34
  62. package/skills/orly-spec-new/SKILL.md +19 -6
  63. package/skills/orly-write-integration-test/SKILL.md +4 -4
  64. package/skills/orly-write-unit-test/SKILL.md +10 -1
  65. package/src/cli.test.ts +2 -2
  66. package/src/cli.ts +19 -75
  67. package/src/cli_help.ts +69 -0
  68. package/src/command_limits.ts +20 -0
  69. package/src/command_process.test.ts +109 -0
  70. package/src/command_process.ts +88 -0
  71. package/src/command_runner.test.ts +88 -0
  72. package/src/command_runner.ts +33 -0
  73. package/src/comprehension_cache.test.ts +123 -0
  74. package/src/config.test.ts +5 -4
  75. package/src/config.ts +26 -164
  76. package/src/config_discovery.ts +159 -0
  77. package/src/config_validation.test.ts +56 -0
  78. package/src/config_validation.ts +25 -0
  79. package/src/criteria.test.ts +1 -1
  80. package/src/criteria.ts +10 -26
  81. package/src/criteria_spec.ts +12 -20
  82. package/src/criteria_support.ts +2 -19
  83. package/src/doc_reads.test.ts +42 -0
  84. package/src/doc_rules.ts +2 -2
  85. package/src/evaluation_resources.test.ts +64 -0
  86. package/src/execution/config.ts +22 -0
  87. package/src/execution/plan.test.ts +65 -0
  88. package/src/execution/plan.ts +51 -0
  89. package/src/gates.ts +39 -24
  90. package/src/gates_spec.test.ts +110 -4
  91. package/src/gates_test_support.ts +9 -7
  92. package/src/install.test.ts +38 -222
  93. package/src/install.ts +117 -308
  94. package/src/install_loaders.test.ts +203 -0
  95. package/src/install_nested.test.ts +2 -2
  96. package/src/install_packs.test.ts +66 -6
  97. package/src/installation/hooks.ts +63 -0
  98. package/src/installation/lock.test.ts +97 -0
  99. package/src/installation/lock.ts +56 -0
  100. package/src/installation/migration.test.ts +212 -0
  101. package/src/installation/migration.ts +65 -0
  102. package/src/installation/plan.ts +96 -0
  103. package/src/installation/transaction.ts +126 -0
  104. package/src/installation_preservation.test.ts +48 -0
  105. package/src/judgments/advice.test.ts +38 -0
  106. package/src/judgments/advice.ts +32 -0
  107. package/src/judgments/command.integration.test.ts +47 -0
  108. package/src/judgments/command.ts +90 -0
  109. package/src/judgments/constants.ts +55 -0
  110. package/src/judgments/evidence.test.ts +48 -0
  111. package/src/judgments/evidence.ts +34 -0
  112. package/src/judgments/files.test.ts +62 -0
  113. package/src/judgments/files.ts +73 -0
  114. package/src/judgments/questions.test.ts +19 -0
  115. package/src/judgments/questions.ts +85 -0
  116. package/src/judgments/replay.test.ts +43 -0
  117. package/src/judgments/replay.ts +66 -0
  118. package/src/judgments/runner.integration.test.ts +165 -0
  119. package/src/judgments/runner.ts +97 -0
  120. package/src/judgments/scan.test.ts +19 -0
  121. package/src/judgments/scan.ts +40 -0
  122. package/src/judgments/syntax.test.ts +115 -0
  123. package/src/judgments/syntax.ts +71 -0
  124. package/src/judgments/syntax_worker.ts +70 -0
  125. package/src/judgments/test_support.ts +60 -0
  126. package/src/judgments/transport.integration.test.ts +63 -0
  127. package/src/judgments/transport.ts +35 -0
  128. package/src/judgments/types.test.ts +43 -0
  129. package/src/judgments/types.ts +66 -0
  130. package/src/judgments/wire.test.ts +48 -0
  131. package/src/judgments/wire.ts +40 -0
  132. package/src/loaders.test.ts +13 -16
  133. package/src/loaders.ts +28 -10
  134. package/src/model.test.ts +20 -3
  135. package/src/model.ts +7 -18
  136. package/src/pack_hygiene.test.ts +42 -1
  137. package/src/pack_hygiene.ts +18 -8
  138. package/src/references.ts +15 -7
  139. package/src/release_inventory.test.ts +80 -0
  140. package/src/render.test.ts +1 -1
  141. package/src/render.ts +3 -8
  142. package/src/required_scanner.test.ts +36 -0
  143. package/src/review_poll.test.ts +158 -0
  144. package/src/scanner_scope.test.ts +202 -0
  145. package/src/setup.test.ts +9 -6
  146. package/src/spec_baseline.test.ts +1 -1
  147. package/src/spec_completion.test.ts +74 -0
  148. package/src/spec_obligations.ts +64 -0
  149. package/src/spec_rehearsal.test.ts +182 -0
  150. package/src/spec_template_io.test.ts +11 -9
  151. package/src/surfaces.test.ts +2 -0
  152. package/src/surfaces.ts +1 -1
  153. package/src/validation.ts +7 -4
  154. package/src/verify.test.ts +18 -2
  155. package/src/verify.ts +7 -3
package/src/config.ts CHANGED
@@ -1,60 +1,27 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
- import { dirname, extname, join, relative } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
 
4
4
  import { assertWritableInside, hashContent, isObject, isString, JsonObject, objectValue, OrlyError, readJsonObject, RulesModel, stringArray } from "./model";
5
5
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
6
+ import { selectPacks, sniffCommands } from "./config_discovery";
7
+ import { readExecution, type ExecutionConfig } from "./execution/config";
6
8
  import { validateCommands, validateSurfaces } from "./validation";
9
+ import { validateConfiguration } from "./config_validation";
10
+ import { commandLimitsSchema, type LaneLimits } from "./command_limits";
7
11
 
8
- const ORACLE_DIRECTORY = ".oracle";
12
+ const ORACLE_DIRECTORY = ".orly";
9
13
  export const CONFIG_PATH = `${ORACLE_DIRECTORY}/orly.json`;
10
14
 
11
15
  const CONFIG_SCHEMA_VERSION = 1;
12
16
  const JSON_INDENT = 2;
13
17
  const NEWLINE = "\n";
14
- const REGISTRY_PACKS_LABEL = "registry packs";
15
- const EXTENSIONS_FIELD = "extensions";
16
18
  const PACKS_FIELD = "packs";
17
19
  const VERSION_FIELD = "orly_version";
18
20
  const MANAGED_FIELD = "managed";
19
21
  const DIGESTS_FIELD = "digests";
20
22
  const DIGEST_ALGORITHM = "sha256";
21
23
  const COMMANDS_FIELD = "commands";
22
- const CONFORM_COMMAND = "conform";
23
- const MAKE_COMMAND = "make";
24
- const BUN_COMMAND = "bun";
25
- const RUN_SUBCOMMAND = "run";
26
- const MAKEFILE = "Makefile";
27
- const PACKAGE_MANIFEST = "package.json";
28
- const SCRIPTS_FIELD = "scripts";
29
- const LINT_ALL_TARGET = "lint-all";
30
- const LINT_TARGET = "lint";
31
- const BUILD_TARGET = "build";
32
24
  const TEXT_ENCODING = "utf8";
33
- const TARGET_PATTERN = /^([A-Za-z0-9][A-Za-z0-9_.-]*):/;
34
- const INCLUDE_PATTERN = /^-?include\s+(\S+)/;
35
-
36
- // Packs a repository only receives when it asks: Kishore's own address handles
37
- // and agentsfleet's product surface mean nothing in a stranger's checkout, so
38
- // they are never inferred — `.oracle/orly.json` names them or they stay out.
39
- const OPT_IN_PACKS = ["persona.indy", "product.agentsfleet", "workflow.governance"];
40
-
41
- // Directories that are never the repository's own source: scanning them makes
42
- // a Rust crate look like it writes TypeScript because one dependency does.
43
- const SKIPPED_DIRECTORIES = new Set([".git", ".oracle", "node_modules", "vendor", "third_party", "target", "dist", "build", ".zig-cache", "zig-out", ".venv", "__pycache__", ".next", ".turbo", ".cache", "coverage", "out"]);
44
- const SCAN_DEPTH = 4;
45
-
46
- // A Makefile target or package script orly knows how to map onto a gate
47
- // command. The first match wins, so the more specific name is listed first.
48
- const CONFORM_TARGETS = ["harness-verify", "conform", "audit", LINT_ALL_TARGET, LINT_TARGET];
49
- const VERIFY_TARGETS: Array<[string, string[]]> = [
50
- ["verify.lint", [LINT_ALL_TARGET, LINT_TARGET]],
51
- ["verify.unit", ["test-unit-all", "test-unit", "test"]],
52
- ["verify.integration", ["test-integration"]],
53
- ["verify.memory", ["memleak"]],
54
- ["verify.version", ["check-version"]],
55
- ["verify.build", [BUILD_TARGET]],
56
- ];
57
-
58
25
  // One file per repository. Two regions, one owner each: `packs`, `commands` and
59
26
  // `surfaces` are the repository's answer and orly only ever reads them;
60
27
  // `orly_version` and `managed` are orly's record of what it installed and what
@@ -68,6 +35,8 @@ export type RepoConfig = {
68
35
  surfaces: JsonObject | undefined;
69
36
  managed: string[];
70
37
  digests: Record<string, string>;
38
+ execution?: ExecutionConfig | undefined;
39
+ limits?: LaneLimits;
71
40
  };
72
41
 
73
42
  export function configPath(targetRoot: string): string {
@@ -98,8 +67,9 @@ export function readConfigSync(targetRoot: string): RepoConfig | undefined {
98
67
  // One shape check for both readers: the declared commands and surfaces are what
99
68
  // `orly gate` runs and diffs against, so a malformed one fails here by name
100
69
  // rather than as a confusing red criterion later.
101
- function parseConfig(value: JsonObject): RepoConfig {
70
+ export function parseConfig(value: JsonObject): RepoConfig {
102
71
  if (value.schema_version !== CONFIG_SCHEMA_VERSION) throw new OrlyError(`${CONFIG_PATH} schema_version must equal ${CONFIG_SCHEMA_VERSION}`);
72
+ validateConfiguration(value);
103
73
  const errors: string[] = [];
104
74
  if (value[COMMANDS_FIELD] !== undefined) validateCommands(CONFIG_PATH, value[COMMANDS_FIELD], errors);
105
75
  validateSurfaces(CONFIG_PATH, value.surfaces, errors);
@@ -113,6 +83,8 @@ function parseConfig(value: JsonObject): RepoConfig {
113
83
  surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
114
84
  managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
115
85
  digests: readDigests(value[DIGESTS_FIELD]),
86
+ execution: readExecution(value.execution),
87
+ ...(isObject(value.limits) ? { limits: Object.fromEntries(Object.entries(value.limits).map(([lane, limits]) => [lane, commandLimitsSchema.parse(limits)])) } : {}),
116
88
  };
117
89
  }
118
90
 
@@ -121,7 +93,8 @@ function parseConfig(value: JsonObject): RepoConfig {
121
93
  // read as "no claim", which is what keeps an older checkout green until its
122
94
  // next update rather than failing it for a record it never had.
123
95
  function readDigests(value: unknown): Record<string, string> {
124
- if (!isObject(value)) return {};
96
+ if (value === undefined) return {};
97
+ if (!isObject(value)) throw new OrlyError(`${CONFIG_PATH} digests must be an object`);
125
98
  const digests: Record<string, string> = {};
126
99
  for (const [path, digest] of Object.entries(value)) if (isString(digest)) digests[path] = digest;
127
100
  return digests;
@@ -144,7 +117,7 @@ export function contentDigest(bytes: Uint8Array): string {
144
117
  // checkout installed on that version is not reported as wholly drifted before
145
118
  // its next update — a format change is not an edit, and saying so would be the
146
119
  // false alarm that teaches people to ignore the real ones.
147
- function sameDigest(recorded: string, actual: string): boolean {
120
+ export function sameDigest(recorded: string, actual: string): boolean {
148
121
  const bare = (digest: string) => digest.startsWith(`${DIGEST_ALGORITHM}:`) ? digest.slice(DIGEST_ALGORITHM.length + 1) : digest;
149
122
  return bare(recorded) === bare(actual);
150
123
  }
@@ -212,7 +185,7 @@ function editedManaged(targetRoot: string, config: RepoConfig, present: string[]
212
185
  if (!recorded) return [];
213
186
  const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
214
187
  if (sameDigest(recorded, actual)) return [];
215
- return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
188
+ return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or explicitly use \`orly update --force\` to discard it`];
216
189
  });
217
190
  }
218
191
 
@@ -222,17 +195,23 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
222
195
  assertWritableInside(targetRoot, CONFIG_PATH, "config");
223
196
  const path = configPath(targetRoot);
224
197
  mkdirSync(dirname(path), { recursive: true });
198
+ await Bun.write(path, serialiseConfig(config));
199
+ return path;
200
+ }
201
+
202
+ export function serialiseConfig(config: RepoConfig): string {
225
203
  const ordered = {
226
204
  schema_version: config.schema_version,
227
205
  orly_version: config.orly_version,
228
206
  packs: config.packs,
229
207
  commands: config.commands,
230
208
  ...(config.surfaces ? { surfaces: config.surfaces } : {}),
209
+ ...(config.execution ? { execution: config.execution } : {}),
210
+ ...(config.limits ? { limits: config.limits } : {}),
231
211
  managed: [...config.managed].sort(),
232
212
  digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
233
213
  };
234
- await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
235
- return path;
214
+ return `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`;
236
215
  }
237
216
 
238
217
  // Seeded once, on the install that finds no config. The commands are a guess
@@ -242,7 +221,7 @@ export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
242
221
  return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
243
222
  }
244
223
 
245
- // What this repository installed and runs with, read from its own `.oracle/`.
224
+ // What this repository installed and runs with, read from its own `.orly/`.
246
225
  // Every caller that used to ask a central registry "which profile is this
247
226
  // checkout" asks the checkout instead, so the answer travels with the clone.
248
227
  export async function localSelection(model: RulesModel, targetRoot: string): Promise<{ packs: string[]; commands: Record<string, string[][]>; surfaces: JsonObject | undefined }> {
@@ -250,123 +229,6 @@ export async function localSelection(model: RulesModel, targetRoot: string): Pro
250
229
  return { packs: selectPacks(model, targetRoot, config?.packs ?? [], new Set(config?.managed ?? [])), commands: config?.commands ?? {}, surfaces: config?.surfaces };
251
230
  }
252
231
 
253
- // Which packs this repository takes: every always-on pack, the language packs
254
- // whose extensions actually appear in its tree, plus whatever it opted into.
255
- // No profile name, no central registry — the repository decides by its own
256
- // contents, so a fresh clone installs correctly with nothing to look up.
257
- export function selectPacks(model: RulesModel, targetRoot: string, requested: string[], managed: Set<string> = new Set()): string[] {
258
- const packs = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
259
- const present = scanExtensions(targetRoot, managed);
260
- const selected = new Set<string>();
261
- for (const [name, value] of Object.entries(packs)) {
262
- if (OPT_IN_PACKS.includes(name)) continue;
263
- const extensions = isObject(value) ? stringArray(value[EXTENSIONS_FIELD] ?? [], `pack ${name} ${EXTENSIONS_FIELD}`) : [];
264
- if (extensions.length === 0 || extensions.some((extension) => present.has(extension))) selected.add(name);
265
- }
266
- for (const name of requested) {
267
- if (!(name in packs)) throw new OrlyError(`${CONFIG_PATH} names an unknown pack: ${name} (available: ${Object.keys(packs).sort().join(", ")})`);
268
- selected.add(name);
269
- }
270
- return [...selected].sort();
271
- }
272
-
273
- // Every file extension the repository's OWN source uses. Bounded in depth and
274
- // blind to dependency directories, so the walk stays cheap on a large tree.
275
- // Files orly itself materialised are excluded: the gate scripts it writes are
276
- // shell, so counting them would select the shell pack on the second install,
277
- // which writes more shell, which selects more — a set that never settles.
278
- function scanExtensions(targetRoot: string, managed: Set<string>, depth = SCAN_DEPTH): Set<string> {
279
- const found = new Set<string>();
280
- const walk = (directory: string, remaining: number): void => {
281
- let entries;
282
- try {
283
- entries = readdirSync(directory, { withFileTypes: true });
284
- } catch {
285
- return;
286
- }
287
- for (const entry of entries) {
288
- if (entry.isDirectory()) {
289
- if (remaining > 0 && !SKIPPED_DIRECTORIES.has(entry.name)) walk(join(directory, entry.name), remaining - 1);
290
- continue;
291
- }
292
- const relativePath = relative(targetRoot, join(directory, entry.name)).replaceAll("\\", "/");
293
- if (managed.has(relativePath)) continue;
294
- const extension = extname(entry.name);
295
- if (extension.length > 0) found.add(extension);
296
- }
297
- };
298
- walk(targetRoot, depth);
299
- return found;
300
- }
301
-
302
- async function sniffCommands(targetRoot: string): Promise<Record<string, string[][]>> {
303
- const make = await makeTargets(targetRoot);
304
- const scripts = await packageScripts(targetRoot);
305
- const commands: Record<string, string[][]> = {};
306
- const conform = pick(CONFORM_TARGETS, make, scripts);
307
- if (conform) commands[CONFORM_COMMAND] = [conform];
308
- for (const [key, candidates] of VERIFY_TARGETS) {
309
- const found = pick(candidates, make, scripts);
310
- // A repository with only `lint` matches both conform and verify.lint. The
311
- // gate would then run one command twice, in two tiers, for one signal.
312
- if (found && !(conform && sameInvocation(found, conform))) commands[key] = [found];
313
- }
314
- return commands;
315
- }
316
-
317
- function sameInvocation(left: string[], right: string[]): boolean {
318
- return left.length === right.length && left.every((argument, index) => argument === right[index]);
319
- }
320
-
321
- function pick(candidates: string[], make: Set<string>, scripts: Set<string>): string[] | undefined {
322
- for (const candidate of candidates) {
323
- if (make.has(candidate)) return [MAKE_COMMAND, candidate];
324
- if (scripts.has(candidate)) return [BUN_COMMAND, RUN_SUBCOMMAND, candidate];
325
- }
326
- return undefined;
327
- }
328
-
329
- // Target names as the Makefile declares them: a line starting at column zero,
330
- // up to the first colon. `include` is followed one level deep — a modular
331
- // Makefile keeps every real target in make/*.mk, so a root-only read finds
332
- // nothing but `help`. Enough to know a target exists, which is all the seed
333
- // needs; the agent completing the config reads the file properly.
334
- async function makeTargets(targetRoot: string): Promise<Set<string>> {
335
- const targets = new Set<string>();
336
- const root = join(targetRoot, MAKEFILE);
337
- if (!existsSync(root)) return targets;
338
- const files = [root, ...(await includedMakefiles(targetRoot, root))];
339
- for (const file of files) {
340
- if (!existsSync(file)) continue;
341
- for (const line of (await Bun.file(file).text()).split(NEWLINE)) {
342
- const match = TARGET_PATTERN.exec(line);
343
- if (match?.[1]) targets.add(match[1]);
344
- }
345
- }
346
- return targets;
347
- }
348
-
349
- async function includedMakefiles(targetRoot: string, root: string): Promise<string[]> {
350
- const included: string[] = [];
351
- for (const line of (await Bun.file(root).text()).split(NEWLINE)) {
352
- const match = INCLUDE_PATTERN.exec(line);
353
- if (match?.[1]) included.push(join(targetRoot, match[1].trim()));
354
- }
355
- return included;
356
- }
357
-
358
- async function packageScripts(targetRoot: string): Promise<Set<string>> {
359
- const path = join(targetRoot, PACKAGE_MANIFEST);
360
- if (!existsSync(path)) return new Set();
361
- try {
362
- const manifest = await readJsonObject(path);
363
- const scripts = manifest[SCRIPTS_FIELD];
364
- return isObject(scripts) ? new Set(Object.keys(scripts)) : new Set();
365
- } catch {
366
- return new Set();
367
- }
368
- }
369
-
370
232
  function readCommands(value: unknown): Record<string, string[][]> {
371
233
  if (value === undefined) return {};
372
234
  const commands: Record<string, string[][]> = {};
@@ -0,0 +1,159 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { extname, join, relative } from "node:path";
3
+ import { isObject, objectValue, OrlyError, readJsonObject, stringArray, type RulesModel } from "./model";
4
+ import { CONFIG_PATH } from "./config";
5
+
6
+ const NEWLINE = "\n";
7
+ const REGISTRY_PACKS_LABEL = "registry packs";
8
+ const EXTENSIONS_FIELD = "extensions";
9
+ const CONFORM_COMMAND = "conform";
10
+ const MAKE_COMMAND = "make";
11
+ const BUN_COMMAND = "bun";
12
+ const RUN_SUBCOMMAND = "run";
13
+ const MAKEFILE = "Makefile";
14
+ const PACKAGE_MANIFEST = "package.json";
15
+ const SCRIPTS_FIELD = "scripts";
16
+ const LINT_ALL_TARGET = "lint-all";
17
+ const LINT_TARGET = "lint";
18
+ const BUILD_TARGET = "build";
19
+ const TARGET_PATTERN = /^([A-Za-z0-9][A-Za-z0-9_.-]*):/;
20
+ const INCLUDE_PATTERN = /^-?include\s+(\S+)/;
21
+
22
+ // Packs a repository only receives when it asks: Kishore's own address handles
23
+ // and agentsfleet's product surface mean nothing in a stranger's checkout, so
24
+ // they are never inferred — `.orly/orly.json` names them or they stay out.
25
+ const OPT_IN_PACKS = ["persona.indy", "product.agentsfleet", "workflow.governance"];
26
+
27
+ // Directories that are never the repository's own source: scanning them makes
28
+ // a Rust crate look like it writes TypeScript because one dependency does.
29
+ const SKIPPED_DIRECTORIES = new Set([".git", ".oracle", ".orly", "node_modules", "vendor", "third_party", "target", "dist", "build", ".zig-cache", "zig-out", ".venv", "__pycache__", ".next", ".turbo", ".cache", "coverage", "out"]);
30
+ const SCAN_DEPTH = 4;
31
+
32
+ // A Makefile target or package script orly knows how to map onto a gate
33
+ // command. The first match wins, so the more specific name is listed first.
34
+ const CONFORM_TARGETS = ["harness-verify", "conform", "audit", LINT_ALL_TARGET, LINT_TARGET];
35
+ const VERIFY_TARGETS: Array<[string, string[]]> = [
36
+ ["verify.lint", [LINT_ALL_TARGET, LINT_TARGET]],
37
+ ["verify.unit", ["test-unit-all", "test-unit", "test"]],
38
+ ["verify.integration", ["test-integration"]],
39
+ ["verify.memory", ["memleak"]],
40
+ ["verify.version", ["check-version"]],
41
+ ["verify.build", [BUILD_TARGET]],
42
+ ];
43
+
44
+ // Which packs this repository takes: every always-on pack, the language packs
45
+ // whose extensions actually appear in its tree, plus whatever it opted into.
46
+ // No profile name, no central registry — the repository decides by its own
47
+ // contents, so a fresh clone installs correctly with nothing to look up.
48
+ export function selectPacks(model: RulesModel, targetRoot: string, requested: string[], managed: Set<string> = new Set()): string[] {
49
+ const packs = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
50
+ const present = scanExtensions(targetRoot, managed);
51
+ const selected = new Set<string>();
52
+ for (const [name, value] of Object.entries(packs)) {
53
+ if (OPT_IN_PACKS.includes(name)) continue;
54
+ const extensions = isObject(value) ? stringArray(value[EXTENSIONS_FIELD] ?? [], `pack ${name} ${EXTENSIONS_FIELD}`) : [];
55
+ if (extensions.length === 0 || extensions.some((extension) => present.has(extension))) selected.add(name);
56
+ }
57
+ for (const name of requested) {
58
+ if (!(name in packs)) throw new OrlyError(`${CONFIG_PATH} names an unknown pack: ${name} (available: ${Object.keys(packs).sort().join(", ")})`);
59
+ selected.add(name);
60
+ }
61
+ return [...selected].sort();
62
+ }
63
+
64
+ // Every file extension the repository's OWN source uses. Bounded in depth and
65
+ // blind to dependency directories, so the walk stays cheap on a large tree.
66
+ // Files orly itself materialised are excluded: the gate scripts it writes are
67
+ // shell, so counting them would select the shell pack on the second install,
68
+ // which writes more shell, which selects more — a set that never settles.
69
+ function scanExtensions(targetRoot: string, managed: Set<string>, depth = SCAN_DEPTH): Set<string> {
70
+ const found = new Set<string>();
71
+ const walk = (directory: string, remaining: number): void => {
72
+ let entries;
73
+ try {
74
+ entries = readdirSync(directory, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.isDirectory()) {
80
+ if (remaining > 0 && !SKIPPED_DIRECTORIES.has(entry.name)) walk(join(directory, entry.name), remaining - 1);
81
+ continue;
82
+ }
83
+ const relativePath = relative(targetRoot, join(directory, entry.name)).replaceAll("\\", "/");
84
+ if (managed.has(relativePath)) continue;
85
+ const extension = extname(entry.name);
86
+ if (extension.length > 0) found.add(extension);
87
+ }
88
+ };
89
+ walk(targetRoot, depth);
90
+ return found;
91
+ }
92
+
93
+ export async function sniffCommands(targetRoot: string): Promise<Record<string, string[][]>> {
94
+ const make = await makeTargets(targetRoot);
95
+ const scripts = await packageScripts(targetRoot);
96
+ const commands: Record<string, string[][]> = {};
97
+ const conform = pick(CONFORM_TARGETS, make, scripts);
98
+ if (conform) commands[CONFORM_COMMAND] = [conform];
99
+ for (const [key, candidates] of VERIFY_TARGETS) {
100
+ const found = pick(candidates, make, scripts);
101
+ // A repository with only `lint` matches both conform and verify.lint. The
102
+ // gate would then run one command twice, in two tiers, for one signal.
103
+ if (found && !(conform && sameInvocation(found, conform))) commands[key] = [found];
104
+ }
105
+ return commands;
106
+ }
107
+
108
+ function sameInvocation(left: string[], right: string[]): boolean {
109
+ return left.length === right.length && left.every((argument, index) => argument === right[index]);
110
+ }
111
+
112
+ function pick(candidates: string[], make: Set<string>, scripts: Set<string>): string[] | undefined {
113
+ for (const candidate of candidates) {
114
+ if (make.has(candidate)) return [MAKE_COMMAND, candidate];
115
+ if (scripts.has(candidate)) return [BUN_COMMAND, RUN_SUBCOMMAND, candidate];
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ // Target names as the Makefile declares them: a line starting at column zero,
121
+ // up to the first colon. `include` is followed one level deep — a modular
122
+ // Makefile keeps every real target in make/*.mk, so a root-only read finds
123
+ // nothing but `help`. Enough to know a target exists, which is all the seed
124
+ // needs; the agent completing the config reads the file properly.
125
+ async function makeTargets(targetRoot: string): Promise<Set<string>> {
126
+ const targets = new Set<string>();
127
+ const root = join(targetRoot, MAKEFILE);
128
+ if (!existsSync(root)) return targets;
129
+ const files = [root, ...(await includedMakefiles(targetRoot, root))];
130
+ for (const file of files) {
131
+ if (!existsSync(file)) continue;
132
+ for (const line of (await Bun.file(file).text()).split(NEWLINE)) {
133
+ const match = TARGET_PATTERN.exec(line);
134
+ if (match?.[1]) targets.add(match[1]);
135
+ }
136
+ }
137
+ return targets;
138
+ }
139
+
140
+ async function includedMakefiles(targetRoot: string, root: string): Promise<string[]> {
141
+ const included: string[] = [];
142
+ for (const line of (await Bun.file(root).text()).split(NEWLINE)) {
143
+ const match = INCLUDE_PATTERN.exec(line);
144
+ if (match?.[1]) included.push(join(targetRoot, match[1].trim()));
145
+ }
146
+ return included;
147
+ }
148
+
149
+ async function packageScripts(targetRoot: string): Promise<Set<string>> {
150
+ const path = join(targetRoot, PACKAGE_MANIFEST);
151
+ if (!existsSync(path)) return new Set();
152
+ try {
153
+ const manifest = await readJsonObject(path);
154
+ const scripts = manifest[SCRIPTS_FIELD];
155
+ return isObject(scripts) ? new Set(Object.keys(scripts)) : new Set();
156
+ } catch {
157
+ return new Set();
158
+ }
159
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { CONFIG_PATH, parseConfig, readConfig, readConfigSync, serialiseConfig } from "./config";
7
+ import { limitsFor, DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_OUTPUT_BYTES } from "./command_limits";
8
+ import type { JsonObject } from "./model";
9
+
10
+ const roots: string[] = [];
11
+ const UNIT_LANE = "verify.unit";
12
+ const BASE = { schema_version: 1, packs: [], commands: { conform: [["true"]], [UNIT_LANE]: [["true"]] } };
13
+
14
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
15
+
16
+ for (const [field, value] of [
17
+ ["packs", null], ["packs", [false]], ["managed", {}], ["managed", [""]],
18
+ ["digests", []], ["digests", { path: false }], ["digests", { path: "broken" }],
19
+ ["orly_version", {}], ["orly_version", "yesterday"], ["schema_version", 2],
20
+ ["unknown_setting", true], ["limits", []], ["limits", { [UNIT_LANE]: { timeout_ms: 0 } }],
21
+ ["limits", { [UNIT_LANE]: { output_bytes: -1 } }], ["limits", { [UNIT_LANE]: { timeout_ms: 1.5 } }],
22
+ ["limits", { [UNIT_LANE]: { typo: 10 } }], ["limits", { [UNIT_LANE]: null }],
23
+ ] as Array<[string, unknown]>) {
24
+ test(`both configuration readers reject invalid ${field} value ${JSON.stringify(value)}`, async () => {
25
+ const root = mkdtempSync(join(tmpdir(), "orly-config-validation-"));
26
+ roots.push(root);
27
+ mkdirSync(join(root, ".orly"));
28
+ writeFileSync(join(root, CONFIG_PATH), JSON.stringify({ ...BASE, [field]: value }));
29
+ expect(() => readConfigSync(root)).toThrow(field === "unknown_setting" ? "Unrecognized key" : field);
30
+ await expect(readConfig(root)).rejects.toThrow(field === "unknown_setting" ? "Unrecognized key" : field);
31
+ });
32
+ }
33
+
34
+ test("configuration limits survive a rewrite and override only their named lane", () => {
35
+ const limits = { [UNIT_LANE]: { timeout_ms: 200, output_bytes: 4096 }, conform: { timeout_ms: 500 } };
36
+ const config = parseConfig({ ...BASE, limits });
37
+ expect(parseConfig(JSON.parse(serialiseConfig(config)))).toEqual(config);
38
+ expect(limitsFor(config.limits, UNIT_LANE)).toEqual(limits[UNIT_LANE]);
39
+ expect(limitsFor(config.limits, "conform")).toEqual({ timeout_ms: 500, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
40
+ expect(limitsFor(config.limits, "verify.integration")).toEqual({ timeout_ms: DEFAULT_COMMAND_TIMEOUT_MS, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
41
+ });
42
+
43
+ test("a minimal seeded configuration normalizes optional ownership records", () => {
44
+ const config = parseConfig({ schema_version: 1 });
45
+ expect(config.packs).toEqual([]);
46
+ expect(config.managed).toEqual([]);
47
+ expect(config.digests).toEqual({});
48
+ expect(config.orly_version).toBe("");
49
+ });
50
+
51
+ test("disabled execution settings round-trip while active remote execution refuses", () => {
52
+ const disabled: JsonObject = { ...BASE, execution: { remote: { mode: "disabled", launcher: ["sentinel"] } } };
53
+ const config = parseConfig(disabled);
54
+ expect(parseConfig(JSON.parse(serialiseConfig(config)))).toEqual(config);
55
+ expect(() => parseConfig({ ...BASE, execution: { remote: { mode: "enabled" } } })).toThrow("unsupported");
56
+ });
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ import { commandLimitsSchema } from "./command_limits";
4
+ import { OrlyError, type JsonObject } from "./model";
5
+
6
+ const SCHEMA_VERSION = 1;
7
+ const DIGEST_PATTERN = /^(?:sha256:)?[a-f0-9]{64}$/;
8
+ const VERSION_PATTERN = /^$|^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/;
9
+ const nonempty = z.string().min(1).refine((value) => !value.includes("\0"));
10
+ const configurationSchema = z.object({
11
+ schema_version: z.literal(SCHEMA_VERSION),
12
+ orly_version: z.string().regex(VERSION_PATTERN).optional(),
13
+ packs: z.array(nonempty).optional(),
14
+ commands: z.unknown().optional(),
15
+ surfaces: z.unknown().optional(),
16
+ managed: z.array(nonempty).optional(),
17
+ digests: z.record(nonempty, z.string().regex(DIGEST_PATTERN)).optional(),
18
+ execution: z.unknown().optional(),
19
+ limits: z.record(nonempty, commandLimitsSchema).optional(),
20
+ }).strict();
21
+
22
+ export function validateConfiguration(value: JsonObject): void {
23
+ const parsed = configurationSchema.safeParse(value);
24
+ if (!parsed.success) throw new OrlyError(`.orly/orly.json invalid repository configuration: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ")}`);
25
+ }
@@ -159,7 +159,7 @@ describe("repo.config", () => {
159
159
  test("a repository with no config names the install command", async () => {
160
160
  const project = newSpecRepository();
161
161
  const context = { root: project, model: await modelFor(project), acceptDirty: false };
162
- rmSync(join(project, ".oracle/orly.json"));
162
+ rmSync(join(project, ".orly/orly.json"));
163
163
  const criterion = criteriaFor(WORK, context).find((entry) => entry.name === REPO_CONFIG);
164
164
  const verdict = criterion?.evaluate(context);
165
165
  expect(verdict?.ok).toBeFalse();
package/src/criteria.ts CHANGED
@@ -12,16 +12,16 @@ import { documentationSurfaces, scanSurfaces } from "./doc_rules";
12
12
  import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
13
13
  import { readConfigSync, RepoConfig } from "./config";
14
14
  import { classifyBranch, SurfaceReport } from "./surfaces";
15
+ import { selectedCommands, CONDITIONAL_LANES, CONFORM_TIER, FAST_TIER, ALL_TIER, type CommandTier } from "./execution/plan";
15
16
  import { commandSetupErrors } from "./validation";
17
+ import { limitsFor, type CommandLimits } from "./command_limits";
16
18
 
17
19
  export type { Criterion, CriterionContext, CriterionResult, Verdict };
18
20
  export { runCommand };
19
21
 
20
22
  const DEFAULT_BRANCHES = ["master", "main"];
21
- const CONFORM_COMMAND = "conform";
22
- const VERIFY_PREFIX = "verify.";
23
23
  const REPOSITORIES_LABEL = "repositories";
24
- const UNINSTALLED = "no .oracle/orly.json here — run `orly init` first";
24
+ const UNINSTALLED = "no .orly/orly.json here — run `orly init` first";
25
25
  const REV_PARSE = "rev-parse";
26
26
  const HEAD = "HEAD";
27
27
  const UPSTREAM = "@{upstream}";
@@ -45,15 +45,6 @@ const NAMED_FINDINGS = 3;
45
45
  // rule audit could only run beside the lint and unit suites, so a repository
46
46
  // paid minutes to learn something its seconds-long conform command already
47
47
  // knew, and `orly gate` ran it twice on the way to `pr`.
48
- const CONFORM_TIER = "conform";
49
- const FAST_TIER = "fast";
50
- const SLOW_TIER = "slow";
51
- // The slow tier is a fixed name set, not a prefix rule: lint and version
52
- // checks are verify.* too, and demoting them to skip-on-prose would be wrong.
53
- const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
54
- const UNIT_COMMAND = "verify.unit";
55
- const ALL_TIER = "all";
56
-
57
48
  // Commit checks read the index. Push checks permit in-flight Sections and omit
58
49
  // the boundary test suites. The final gate runs all declared verification
59
50
  // itself, including checks a custom repository hook may not have invoked.
@@ -115,30 +106,23 @@ function repositoryConfig(): Criterion {
115
106
 
116
107
  // Commands and source paths are repository-owned. Only integration and memory
117
108
  // checks are conditional on code; every other declared lane always runs.
118
- function commandCriteria(context: CriterionContext, tier: string): Criterion[] {
109
+ function commandCriteria(context: CriterionContext, tier: CommandTier): Criterion[] {
119
110
  const config = resolvedConfig(context);
120
111
  if (!config) return [];
121
112
  const commands = config.commands;
122
- const selected = Object.keys(commands).filter((key) => tier === ALL_TIER ? key.startsWith(VERIFY_PREFIX) : tierOf(key) === tier).sort();
113
+ const selected = selectedCommands(commands, tier);
123
114
  return selected.map((key) => criterion(`cmd.${key}`, (inner) => {
124
- if (SLOW_COMMANDS.includes(key) && report(inner, config.surfaces).code.length === 0) {
115
+ if (CONDITIONAL_LANES.includes(key) && report(inner, config.surfaces).code.length === 0) {
125
116
  return { ok: true, detail: "skipped — no code files on this branch" };
126
117
  }
127
- return runInvocations(inner.root, commands[key]);
118
+ return runInvocations(inner.root, commands[key], limitsFor(config.limits, key));
128
119
  }));
129
120
  }
130
121
 
131
- function tierOf(key: string): string {
132
- if (key === CONFORM_COMMAND) return CONFORM_TIER;
133
- if (key === UNIT_COMMAND || SLOW_COMMANDS.includes(key)) return SLOW_TIER;
134
- if (key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
135
- return "";
136
- }
137
-
138
122
  function docsUpdated(): Criterion {
139
123
  return criterion(DOCS_UPDATED, (context) => {
140
124
  const config = resolvedConfig(context);
141
- if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .oracle/orly.json" };
125
+ if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .orly/orly.json" };
142
126
  const surfaces = report(context, config.surfaces);
143
127
  if (surfaces.userSurface.length === 0) return { ok: true, detail: "no user-surface files on this branch" };
144
128
  if (surfaces.docs.length > 0) return { ok: true, detail: `${surfaces.userSurface.length} user-surface file(s), ${surfaces.docs.length} docs file(s) updated` };
@@ -191,13 +175,13 @@ function resolvedConfig(context: CriterionContext): RepoConfig | undefined {
191
175
  }
192
176
  }
193
177
 
194
- function runInvocations(root: string, invocations: unknown): Verdict {
178
+ function runInvocations(root: string, invocations: unknown, limits: CommandLimits): Verdict {
195
179
  if (!Array.isArray(invocations) || invocations.length === 0) return { ok: false, detail: "command group is empty" };
196
180
  const evidence: string[] = [];
197
181
  for (const invocation of invocations) {
198
182
  if (!Array.isArray(invocation) || invocation.length === 0) return { ok: false, detail: "command invocation is empty" };
199
183
  const argv = invocation.map((argument) => String(argument));
200
- const result = runCommand(root, argv);
184
+ const result = runCommand(root, argv, limits);
201
185
  evidence.push(`${argv.join(" ")} -> ${result.detail}`);
202
186
  if (!result.ok) return { ok: false, detail: evidence.join(NEWLINE) };
203
187
  }