@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
@@ -0,0 +1,123 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { copyFileSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+
8
+ const SCRIPT = "evals/llms/run.sh";
9
+ const AGENT_NAMES = ["claude", "codex", "amp", "opencode"];
10
+ const CALLS = "calls.txt";
11
+ const AGENTS = "AGENTS.md";
12
+ const FACADE = "dispatch/write_fixture.md";
13
+ const PIPE_OUTPUT = "pipe";
14
+ const TEXT_ENCODING = "utf8";
15
+ const CASE_TIMEOUT_MS = 30_000;
16
+
17
+ afterEach(cleanupTemporaryDirectories);
18
+
19
+ function fixture(missingHost = false) {
20
+ const root = newRepository();
21
+ for (const file of [SCRIPT, "evals/llms/agents.sh", "evals/llms/fixtures.sh", "evals/llms/cache.ts", "skills/orly-write-unit-test/SKILL.md", "skills/orly-write-integration-test/SKILL.md"]) {
22
+ mkdirSync(join(root, file, ".."), { recursive: true });
23
+ copyFileSync(join(ROOT, file), join(root, file));
24
+ }
25
+ mkdirSync(join(root, "dispatch"));
26
+ writeFileSync(join(root, AGENTS), "# Deterministic rules\n");
27
+ writeFileSync(join(root, FACADE), "Require the declared check.\n");
28
+ writeFileSync(join(root, "evals/llms/fixtures.jsonl"), JSON.stringify({ id: "one", mode: "control", expect: "YES", q: "Does the rule require its check?", why: "Local adapter control.", ctx: [FACADE] }) + "\n");
29
+ const tools = join(root, "tools");
30
+ mkdirSync(tools);
31
+ for (const name of ["bash", "sh", "bun", "python3", "timeout", "dirname", "cat", "mktemp", "rm", "ln", "chmod", "cut", "grep", "tr", "sed", "awk", "mkdir", "basename", "git"]) {
32
+ const target = Bun.which(name);
33
+ if (target) symlinkSync(target, join(tools, name));
34
+ }
35
+ for (const host of AGENT_NAMES.filter((name) => !missingHost || name !== "opencode")) {
36
+ writeFileSync(join(tools, host), `#!/bin/sh
37
+ printf '%s\n' '${host}' >> "$ORLY_STUB_CALLS"
38
+ answer=YES
39
+ if [ '${host}' = opencode ] && [ "\${ORLY_STUB_FAIL:-0}" = 1 ]; then answer=NO; fi
40
+ if [ '${host}' = codex ]; then
41
+ cat >/dev/null
42
+ while [ "$#" -gt 0 ]; do
43
+ if [ "$1" = --output-last-message ]; then shift; printf 'VERDICT: %s\n' "$answer" > "$1"; break; fi
44
+ shift
45
+ done
46
+ else
47
+ if [ '${host}' != opencode ]; then cat >/dev/null; fi
48
+ printf 'VERDICT: %s\n' "$answer"
49
+ fi
50
+ `, { mode: 0o755 });
51
+ }
52
+ const env = { ...UNSCOPED_ENVIRONMENT, PATH: tools, ORLY_STUB_CALLS: join(root, CALLS) };
53
+ return { root, env };
54
+ }
55
+
56
+ function run(f: ReturnType<typeof fixture>, args: string[] = [], fail = true) {
57
+ const result = Bun.spawnSync([Bun.which("bash")!, join(f.root, SCRIPT), ...args], {
58
+ cwd: f.root, env: { ...f.env, ORLY_STUB_FAIL: fail ? "1" : "0" }, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, timeout: 30_000,
59
+ });
60
+ return { code: result.exitCode, output: result.stdout.toString() + result.stderr.toString() };
61
+ }
62
+
63
+ function calls(f: ReturnType<typeof fixture>): number { return readFileSync(join(f.root, CALLS), TEXT_ENCODING).trim().split("\n").length; }
64
+
65
+ test("changed prompt bytes invalidate saved results without a new commit", () => {
66
+ const f = fixture();
67
+ const first = run(f);
68
+ expect(first.code, first.output).toBe(1);
69
+ expect(calls(f)).toBe(4);
70
+ expect(run(f).output).toContain("resumed from journal");
71
+ expect(calls(f)).toBe(4);
72
+ writeFileSync(join(f.root, AGENTS), "# Changed deterministic rule\n");
73
+ const changed = run(f);
74
+ expect(changed.code).toBe(1);
75
+ expect(changed.output).not.toContain("resumed from journal");
76
+ expect(calls(f)).toBe(8);
77
+ writeFileSync(join(f.root, FACADE), "A changed check remains required.\n");
78
+ expect(run(f).output).not.toContain("resumed from journal");
79
+ expect(calls(f)).toBe(12);
80
+ }, CASE_TIMEOUT_MS);
81
+
82
+ test("host selection and adapter settings cannot reuse incompatible saved proof", () => {
83
+ const f = fixture();
84
+ run(f);
85
+ const partial = run(f, ["--agent", "claude"]);
86
+ expect(partial.code).toBe(0);
87
+ expect(partial.output).toContain("partial comprehension coverage");
88
+ expect(partial.output).not.toContain("live comprehension passed");
89
+ expect(calls(f)).toBe(5);
90
+ mkdirSync(join(f.root, ".claude"));
91
+ writeFileSync(join(f.root, ".claude/settings.json"), JSON.stringify({ model: "changed-local-control" }));
92
+ expect(run(f).output).not.toContain("resumed from journal");
93
+ expect(calls(f)).toBe(9);
94
+ }, CASE_TIMEOUT_MS);
95
+
96
+ test("malformed journal counts are discarded instead of becoming a pass", () => {
97
+ const f = fixture();
98
+ run(f);
99
+ const journalRoot = join(f.root, ".llmevals-journal");
100
+ const runDirectory = join(journalRoot, readdirSync(journalRoot)[0]!);
101
+ const path = join(runDirectory, "claude");
102
+ const saved = JSON.parse(readFileSync(path, TEXT_ENCODING));
103
+ writeFileSync(path, JSON.stringify({ ...saved, status: "PASS", correct: 0, total: 0 }));
104
+ expect(run(f).code).toBe(1);
105
+ expect(calls(f)).toBe(5);
106
+ }, CASE_TIMEOUT_MS);
107
+
108
+ test("a missing required host keeps full coverage incomplete", () => {
109
+ const f = fixture(true);
110
+ const result = run(f, [], false);
111
+ expect(result.code).toBe(1);
112
+ expect(result.output).toContain("requested agent absent: opencode");
113
+ expect(result.output).not.toContain("live comprehension passed");
114
+ expect(calls(f)).toBe(3);
115
+ }, CASE_TIMEOUT_MS);
116
+
117
+ test("full completion requires every selected fixture on all required local stub hosts", () => {
118
+ const f = fixture();
119
+ const result = run(f, [], false);
120
+ expect(result.code).toBe(0);
121
+ expect(result.output).toContain("graded=4/4");
122
+ expect(calls(f)).toBe(4);
123
+ }, CASE_TIMEOUT_MS);
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
 
6
- import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
6
+ import { selectPacks } from "./config_discovery";
7
+ import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, staleVersion, writeConfig } from "./config";
7
8
  import { RulesModel } from "./model";
8
9
 
9
10
  const ROOT = resolve(import.meta.dir, "..");
@@ -20,7 +21,7 @@ function scratch(): string {
20
21
  const temporary: string[] = [];
21
22
 
22
23
  function writeConfigText(root: string, body: unknown): void {
23
- mkdirSync(join(root, ".oracle"), { recursive: true });
24
+ mkdirSync(join(root, ".orly"), { recursive: true });
24
25
  writeFileSync(join(root, CONFIG_PATH), JSON.stringify(body));
25
26
  }
26
27
 
@@ -236,7 +237,7 @@ describe("managedDrift", () => {
236
237
  });
237
238
 
238
239
  // The failure this pair exists for: agentsfleet ignored .claude/skills/ while
239
- // .oracle/orly.json declared four files under it managed. Every local
240
+ // .orly/orly.json declared four files under it managed. Every local
240
241
  // checkout passed `orly doctor` because the files sat on disk, and CI failed
241
242
  // against a clone that never received them.
242
243
  test("a managed file git refuses to track is drift, even though it is present", async () => {
@@ -294,7 +295,7 @@ describe("writeConfig", () => {
294
295
  expect((await readConfig(root))?.managed).toEqual(["aaa.md", "zzz.md"]);
295
296
  });
296
297
 
297
- test("writeConfig creates the .oracle/ parent directory", async () => {
298
+ test("writeConfig creates the .orly/ parent directory", async () => {
298
299
  const root = scratch();
299
300
  await writeConfig(root, await seedConfig(root));
300
301
  expect(existsSync(join(root, CONFIG_PATH))).toBeTrue();
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
+ });