@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.
- package/.githooks/pre-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +65 -29
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +18 -6
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +153 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +37 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +39 -24
- package/src/gates_spec.test.ts +110 -4
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
package/src/cli_help.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -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);
|
package/src/config.test.ts
CHANGED
|
@@ -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 {
|
|
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, ".
|
|
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
|
-
// .
|
|
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 .
|
|
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();
|