@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/criteria_spec.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { readConfigSync } from "./config";
|
|
4
4
|
import { Criterion, CriterionContext, criterion, gitOutput, runCommand, Verdict } from "./criteria_support";
|
|
5
5
|
import { branchDiff, classifyBranch, defaultMergeBase } from "./surfaces";
|
|
6
|
+
import { specDimensionsOf, unacknowledgedObligations } from "./spec_obligations";
|
|
6
7
|
|
|
7
8
|
const BASELINE_HEADER = "Test Baseline";
|
|
8
9
|
const REVISION_HEADER = "Baseline revision";
|
|
@@ -14,8 +15,8 @@ const NOT_APPLICABLE = /^n\/a\s+[—-]\s+\S/i;
|
|
|
14
15
|
|
|
15
16
|
const OPEN_QUESTION = "[?]";
|
|
16
17
|
const PRODUCT_CLARITY_HEADING = "## Product Clarity";
|
|
17
|
-
const DIMENSION_PREFIX = "- **Dimension ";
|
|
18
18
|
const DONE_MARKER = "DONE";
|
|
19
|
+
const ITEM_SEPARATOR = ", ";
|
|
19
20
|
const SPEC_GATE_SCRIPT = "audits/spec-template.sh";
|
|
20
21
|
const NO_SPEC_SKIP = "skipped — no active spec (quality gates still apply)";
|
|
21
22
|
|
|
@@ -27,10 +28,6 @@ const SPEC_MOVED = "spec.moved";
|
|
|
27
28
|
const SPEC_BASELINE = "spec.baseline";
|
|
28
29
|
const SPEC_ORDERING = "spec.ordering";
|
|
29
30
|
const SPEC_DEFERRALS = "spec.deferrals";
|
|
30
|
-
const STATUS_DONE = "Status: DONE";
|
|
31
|
-
const INDY_ACK = "> Indy (";
|
|
32
|
-
// deferred/deferral(s) only — never Zig's defer/errdefer keywords.
|
|
33
|
-
const DEFERRAL_CLAIM = /\bdeferr(ed|al|als)\b/i;
|
|
34
31
|
const SPEC_TREE_FILE = /^docs\/v[^/]+\/.+\.md$/;
|
|
35
32
|
|
|
36
33
|
// Wrap a spec-reading criterion: no active spec → skip-pass with the reason
|
|
@@ -43,7 +40,7 @@ function specCriterion(name: string, evaluate: (context: CriterionContext) => Ve
|
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
export function specGate(): Criterion {
|
|
46
|
-
return specCriterion(SPEC_GATE, (context) => runCommand(context.root, ["bash", SPEC_GATE_SCRIPT
|
|
43
|
+
return specCriterion(SPEC_GATE, (context) => runCommand(context.root, ["bash", context.root === context.model.root ? SPEC_GATE_SCRIPT : `.orly/${SPEC_GATE_SCRIPT}`, "--file", context.specPath ?? ""]));
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
export function openQuestions(): Criterion {
|
|
@@ -62,13 +59,14 @@ export function productClarity(): Criterion {
|
|
|
62
59
|
|
|
63
60
|
export function specDimensions(): Criterion {
|
|
64
61
|
return specCriterion(SPEC_DIMENSIONS, (context) => {
|
|
65
|
-
const dimensions =
|
|
66
|
-
|
|
62
|
+
const dimensions = specDimensionsOf(context.specText ?? "");
|
|
63
|
+
if (!dimensions.length) return { ok: false, detail: "no Dimensions declared in Sections" };
|
|
64
|
+
const open = dimensions.filter((dimension) => !dimension.done);
|
|
67
65
|
return {
|
|
68
66
|
ok: open.length === 0,
|
|
69
67
|
detail: open.length === 0
|
|
70
68
|
? `${dimensions.length} dimension(s) marked ${DONE_MARKER}`
|
|
71
|
-
: `${open.length} of ${dimensions.length} not ${DONE_MARKER}: ${
|
|
69
|
+
: `${open.length} of ${dimensions.length} not ${DONE_MARKER}: ${open.map((dimension) => dimension.id).join(ITEM_SEPARATOR)}`,
|
|
72
70
|
};
|
|
73
71
|
});
|
|
74
72
|
}
|
|
@@ -134,27 +132,21 @@ export function specOrdering(): Criterion {
|
|
|
134
132
|
// the spec; agent-unilateral deferral is incomplete scope, not deferral.
|
|
135
133
|
export function specDeferrals(): Criterion {
|
|
136
134
|
return specCriterion(SPEC_DEFERRALS, (context) => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
? { ok: true, detail: `${claims.length} deferral line(s), Indy ack quote present` }
|
|
142
|
-
: { ok: false, detail: `${claims.length} deferral line(s) with no "${INDY_ACK}" ack quote — agent-unilateral deferral is incomplete scope` };
|
|
135
|
+
const missing = unacknowledgedObligations(context.specText ?? "");
|
|
136
|
+
return missing.length === 0
|
|
137
|
+
? { ok: true, detail: "named deferrals and transfers have item-bound owner quotes in Discovery; quote provenance and scope require review" }
|
|
138
|
+
: { ok: false, detail: `${missing.join(ITEM_SEPARATOR)} has no item-bound Indy acknowledgement in Discovery — agent-unilateral deferral is incomplete scope` };
|
|
143
139
|
});
|
|
144
140
|
}
|
|
145
141
|
|
|
146
142
|
function statusDone(context: CriterionContext): boolean {
|
|
147
|
-
return
|
|
143
|
+
return header(context.specText ?? "", "Status") === DONE_MARKER;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
function specLines(context: CriterionContext): string[] {
|
|
151
147
|
return (context.specText ?? "").split(/\r?\n/);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
|
-
function dimensionLabels(lines: string[]): string {
|
|
155
|
-
return lines.map((line) => line.trim().slice(DIMENSION_PREFIX.length).split("*")[0]?.trim() ?? "?").join(", ");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
150
|
function baselineEvidence(context: CriterionContext): Verdict {
|
|
159
151
|
const text = context.specText ?? "";
|
|
160
152
|
const revision = header(text, REVISION_HEADER);
|
package/src/criteria_support.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
|
2
2
|
import { RulesModel } from "./model";
|
|
3
3
|
import { SurfaceReport } from "./surfaces";
|
|
4
4
|
|
|
5
|
+
export { runCommand } from "./command_runner";
|
|
6
|
+
|
|
5
7
|
const PIPE_OUTPUT = "pipe";
|
|
6
|
-
const NO_OUTPUT = "no output";
|
|
7
8
|
|
|
8
9
|
export type Verdict = { ok: boolean; detail: string };
|
|
9
10
|
export type CriterionResult = Verdict & { name: string };
|
|
@@ -30,24 +31,6 @@ export function criterion(name: string, evaluate: (context: CriterionContext) =>
|
|
|
30
31
|
return { name, evaluate: (context) => ({ name, ...evaluate(context) }) };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function runCommand(root: string, command: string[]): Verdict {
|
|
34
|
-
// Profile command surfaces are repository-declared, so a named binary may
|
|
35
|
-
// simply not be installed here. Bun.spawnSync THROWS in that case; a gate
|
|
36
|
-
// must report every criterion as a verdict, never abort the run, so the
|
|
37
|
-
// missing binary becomes a red line naming the command.
|
|
38
|
-
let result;
|
|
39
|
-
try {
|
|
40
|
-
result = Bun.spawnSync(command, { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
41
|
-
} catch (error) {
|
|
42
|
-
return { ok: false, detail: `${command[0] ?? "command"} could not be run: ${error instanceof Error ? error.message : String(error)}` };
|
|
43
|
-
}
|
|
44
|
-
const output = [result.stdout.toString(), result.stderr.toString()].filter(Boolean).join("\n").trim();
|
|
45
|
-
if (output) return { ok: result.exitCode === 0, detail: `exit ${result.exitCode}:\n${output}` };
|
|
46
|
-
return result.exitCode === 0
|
|
47
|
-
? { ok: true, detail: "exit 0" }
|
|
48
|
-
: { ok: false, detail: `exit ${result.exitCode}: ${NO_OUTPUT}` };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
34
|
export function gitOutput(root: string, command: string[]): string {
|
|
52
35
|
const result = Bun.spawnSync(["git", ...command], { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
53
36
|
return result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, 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 READ_LOG = ".git/orly/doc-reads.jsonl";
|
|
9
|
+
const CITATION = 'Quoted "section", tab\tline\nnext\rline and control\u0001 with \\ slash.';
|
|
10
|
+
const AUDIT_SCRIPT = "audits/doc-read.sh";
|
|
11
|
+
const READ_HELPER = "audits/doc-reads.ts";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanupTemporaryDirectories);
|
|
14
|
+
|
|
15
|
+
for (const filename of ['we"ird.md', "tab\tpage.md", "line\npage.md", "control\u0001page.md"]) {
|
|
16
|
+
test(`a document read round-trips the path ${JSON.stringify(filename)} and complete citation`, () => {
|
|
17
|
+
const root = newRepository();
|
|
18
|
+
writeFileSync(join(root, filename), "Read this rule.\n");
|
|
19
|
+
const result = Bun.spawnSync(["bash", join(ROOT, AUDIT_SCRIPT), "log", filename, CITATION], { cwd: root, env: { ...UNSCOPED_ENVIRONMENT, ORLY_ROOT: root } });
|
|
20
|
+
expect(result.exitCode).toBe(0);
|
|
21
|
+
const rows = readFileSync(join(root, READ_LOG), "utf8").trimEnd().split("\n").map((line) => JSON.parse(line));
|
|
22
|
+
expect(rows).toHaveLength(1);
|
|
23
|
+
expect(rows[0].path).toBe(filename);
|
|
24
|
+
expect(rows[0].section).toBe(CITATION);
|
|
25
|
+
const current = Bun.spawnSync([process.execPath, join(ROOT, READ_HELPER), "current", join(root, READ_LOG), root]);
|
|
26
|
+
expect(current.exitCode).toBe(0);
|
|
27
|
+
const fields = current.stdout.toString().trimEnd().split("\t");
|
|
28
|
+
expect(fields).toHaveLength(3);
|
|
29
|
+
expect(JSON.parse(`"${fields[0]}"`)).toBe(filename);
|
|
30
|
+
expect(JSON.parse(`"${fields[2]}"`)).toBe(CITATION);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("malformed saved rows cannot invent a current document read", () => {
|
|
35
|
+
const root = newRepository();
|
|
36
|
+
mkdirSync(join(root, ".git/orly"));
|
|
37
|
+
writeFileSync(join(root, READ_LOG), 'broken JSON\n{"path":"README.md","blob":"bad","ts":"not a time","section":null}\n');
|
|
38
|
+
appendFileSync(join(root, READ_LOG), JSON.stringify({ path: "../../outside", blob: "abc", ts: 1, section: "invented" }) + "\n");
|
|
39
|
+
const result = Bun.spawnSync([process.execPath, join(ROOT, READ_HELPER), "current", join(root, READ_LOG), root]);
|
|
40
|
+
expect(result.exitCode).toBe(0);
|
|
41
|
+
expect(result.stdout.toString()).toBe("");
|
|
42
|
+
});
|
package/src/doc_rules.ts
CHANGED
|
@@ -53,7 +53,7 @@ function isRulePage(file: string): boolean {
|
|
|
53
53
|
return RULE_PAGE_FILES.includes(name);
|
|
54
54
|
}
|
|
55
55
|
const README = "README.md";
|
|
56
|
-
const CONFIG_RELATIVE_PATH = ".
|
|
56
|
+
const CONFIG_RELATIVE_PATH = ".orly/orly.json";
|
|
57
57
|
const REGISTRY_FILE = "registry.json";
|
|
58
58
|
const CODE_PLACEHOLDER = "Code";
|
|
59
59
|
const EMPTY = "";
|
|
@@ -130,7 +130,7 @@ const RULE_NAMES: Record<string, string> = {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
// The pages orly renders into a repository, plus its readme. The materialised
|
|
133
|
-
// set is authoritative and it is recorded, not guessed: `.
|
|
133
|
+
// set is authoritative and it is recorded, not guessed: `.orly/orly.json`
|
|
134
134
|
// names every file orly wrote here. In the engine's own checkout the registry
|
|
135
135
|
// names the sources those files are rendered from, which is the same set one
|
|
136
136
|
// step earlier. README.md joins either list by the scope rule in
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, 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 BASH = "bash";
|
|
9
|
+
const PIPE = "pipe";
|
|
10
|
+
const OWNER_FILE = "owner.txt";
|
|
11
|
+
const OWNER_CONTENT = "caller-owned bytes\n";
|
|
12
|
+
const LEDGER_LIBRARY = "evals/ledger/lib.sh";
|
|
13
|
+
const TEST_TIMEOUT_MS = 20_000;
|
|
14
|
+
const INTERRUPTED_EXIT = 143;
|
|
15
|
+
const FAILURE_EXIT = 7;
|
|
16
|
+
|
|
17
|
+
afterEach(cleanupTemporaryDirectories);
|
|
18
|
+
|
|
19
|
+
function environment(root: string) {
|
|
20
|
+
writeFileSync(join(root, OWNER_FILE), OWNER_CONTENT);
|
|
21
|
+
return { ...UNSCOPED_ENVIRONMENT, TMPDIR: root, LEDGER_LIBRARY: join(ROOT, LEDGER_LIBRARY) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectClean(root: string) {
|
|
25
|
+
expect(readdirSync(root)).toEqual([OWNER_FILE]);
|
|
26
|
+
expect(readFileSync(join(root, OWNER_FILE), "utf8")).toBe(OWNER_CONTENT);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const script of ["evals/ledger/run.sh", "evals/dispatch/parity.sh"]) {
|
|
30
|
+
test(`evaluation cleanup preserves caller state after ${script}`, () => {
|
|
31
|
+
const root = temporaryDirectory();
|
|
32
|
+
const result = Bun.spawnSync([BASH, join(ROOT, script)], { cwd: ROOT, env: environment(root), stdout: PIPE, stderr: PIPE, timeout: TEST_TIMEOUT_MS });
|
|
33
|
+
expect(result.exitCode).toBe(0);
|
|
34
|
+
expect(result.stdout.toString()).toContain("passed, 0 failed");
|
|
35
|
+
expectClean(root);
|
|
36
|
+
}, TEST_TIMEOUT_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("ledger command-substitution fixtures are removed after a failing caller", () => {
|
|
40
|
+
const root = temporaryDirectory();
|
|
41
|
+
const result = Bun.spawnSync([BASH, "-c", 'source "$LEDGER_LIBRARY"; sb="$(mk_root)"; [[ -d "$sb" ]] || exit 1; exit 7'], { env: environment(root), stdout: PIPE, stderr: PIPE });
|
|
42
|
+
expect(result.exitCode).toBe(FAILURE_EXIT);
|
|
43
|
+
expectClean(root);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ledger command-substitution fixtures are removed after termination", async () => {
|
|
47
|
+
const root = temporaryDirectory();
|
|
48
|
+
const child = Bun.spawn([BASH, "-c", 'source "$LEDGER_LIBRARY"; sb="$(mk_root)"; printf "%s\\n" "$sb"; read -r pending'], { env: environment(root), stdin: PIPE, stdout: PIPE, stderr: PIPE });
|
|
49
|
+
try {
|
|
50
|
+
const reader = child.stdout.getReader();
|
|
51
|
+
const ready = await reader.read();
|
|
52
|
+
expect(ready.done).toBeFalse();
|
|
53
|
+
const fixture = new TextDecoder().decode(ready.value).trim();
|
|
54
|
+
expect(existsSync(fixture)).toBeTrue();
|
|
55
|
+
expect(fixture.startsWith(root)).toBeTrue();
|
|
56
|
+
child.kill("SIGTERM");
|
|
57
|
+
expect(await child.exited).toBe(INTERRUPTED_EXIT);
|
|
58
|
+
expectClean(root);
|
|
59
|
+
} finally {
|
|
60
|
+
child.stdin.end();
|
|
61
|
+
child.kill();
|
|
62
|
+
await child.exited;
|
|
63
|
+
}
|
|
64
|
+
}, TEST_TIMEOUT_MS);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { OrlyError } from "../model";
|
|
3
|
+
|
|
4
|
+
export const DISABLED_MODE = "disabled";
|
|
5
|
+
const MAX_ARGUMENTS = 32;
|
|
6
|
+
const MAX_ARGUMENT_LENGTH = 1024;
|
|
7
|
+
const argument = z.string().min(1).max(MAX_ARGUMENT_LENGTH).refine((value) => !value.includes("\0"));
|
|
8
|
+
const executionSchema = z.object({
|
|
9
|
+
remote: z.object({
|
|
10
|
+
mode: z.literal(DISABLED_MODE),
|
|
11
|
+
launcher: z.array(argument).min(1).max(MAX_ARGUMENTS).optional(),
|
|
12
|
+
}).strict(),
|
|
13
|
+
}).strict();
|
|
14
|
+
|
|
15
|
+
export type ExecutionConfig = z.infer<typeof executionSchema>;
|
|
16
|
+
|
|
17
|
+
export function readExecution(value: unknown): ExecutionConfig | undefined {
|
|
18
|
+
if (value === undefined) return undefined;
|
|
19
|
+
const parsed = executionSchema.safeParse(value);
|
|
20
|
+
if (!parsed.success) throw new OrlyError("execution.remote requires mode=disabled and an optional nonempty launcher argument array; remote activation is unsupported");
|
|
21
|
+
return parsed.data;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfig, seedConfig, writeConfig } from "../config";
|
|
4
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "../gates_test_support";
|
|
5
|
+
import { runGate } from "../gates";
|
|
6
|
+
import { RulesModel } from "../model";
|
|
7
|
+
import { readExecution } from "./config";
|
|
8
|
+
import { lifecyclePlan } from "./plan";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanupTemporaryDirectories);
|
|
11
|
+
|
|
12
|
+
test("lifecycle_plan_names_actual_commands", async () => {
|
|
13
|
+
const repo = newRepository();
|
|
14
|
+
const config = await seedConfig(repo);
|
|
15
|
+
config.commands = { conform: [["true"]], "verify.lint": [["true"]], "verify.unit": [["false"]], "verify.integration": [["false"]] };
|
|
16
|
+
await writeConfig(repo, config);
|
|
17
|
+
const plan = lifecyclePlan(repo);
|
|
18
|
+
const verify = plan.stages.find((stage) => stage.name === "VERIFY")!;
|
|
19
|
+
expect("pushCommands" in verify && verify.pushCommands?.map((command) => command.lane)).toEqual(["verify.lint"]);
|
|
20
|
+
expect("boundaryCommands" in verify && verify.boundaryCommands?.map((command) => command.lane)).toEqual(["verify.integration", "verify.lint", "verify.unit"]);
|
|
21
|
+
expect(plan.remote.evidence).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("disabled_adapter_has_no_side_effects", async () => {
|
|
25
|
+
const repo = newRepository();
|
|
26
|
+
const config = await seedConfig(repo);
|
|
27
|
+
const sentinel = join(repo, "worker-ran");
|
|
28
|
+
config.execution = { remote: { mode: "disabled", launcher: ["touch", sentinel] } };
|
|
29
|
+
config.commands = { conform: [["false"]], "verify.unit": [["true"]] };
|
|
30
|
+
await writeConfig(repo, config);
|
|
31
|
+
expect(lifecyclePlan(repo).remote.launches).toBe(0);
|
|
32
|
+
const result = runGate(await RulesModel.load(ROOT), repo, "work", true);
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
expect(result.results.find((row) => row.name === "cmd.conform")?.ok).toBe(false);
|
|
35
|
+
expect(await Bun.file(sentinel).exists()).toBe(false);
|
|
36
|
+
expect((await readConfig(repo))?.execution).toEqual(config.execution);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each([
|
|
40
|
+
null, {}, { remote: {} }, { remote: { mode: "enabled" } },
|
|
41
|
+
{ remote: { mode: "disabled", launcher: "agentsfleet run" } },
|
|
42
|
+
{ remote: { mode: "disabled", launcher: [] } },
|
|
43
|
+
{ remote: { mode: "disabled", launcher: [""] } },
|
|
44
|
+
{ remote: { mode: "disabled", launcher: ["x\0y"] } },
|
|
45
|
+
{ remote: { mode: "disabled", activate: true } },
|
|
46
|
+
])("remote_adapter_is_disabled rejects malformed or active configuration: %j", (input) => {
|
|
47
|
+
expect(() => readExecution(input)).toThrow("remote activation is unsupported");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("disabled_adapter_never_becomes_pass", async () => {
|
|
51
|
+
const repo = newRepository();
|
|
52
|
+
const { configPath } = await import("../config");
|
|
53
|
+
const config = await seedConfig(repo);
|
|
54
|
+
await Bun.write(configPath(repo), JSON.stringify({ ...config, execution: { remote: { mode: "enabled" } } }));
|
|
55
|
+
expect(() => lifecyclePlan(repo)).toThrow("remote activation is unsupported");
|
|
56
|
+
const result = runGate(await RulesModel.load(ROOT), repo, "work", true);
|
|
57
|
+
expect(result.ok).toBe(false);
|
|
58
|
+
expect(result.results.find((row) => row.name === "repo.config")?.detail).toContain("remote activation is unsupported");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("missing adapter defaults to disabled and missing repository fails", () => {
|
|
62
|
+
expect(readExecution(undefined)).toBeUndefined();
|
|
63
|
+
expect(readExecution({ remote: { mode: "disabled" } })).toEqual({ remote: { mode: "disabled" } });
|
|
64
|
+
expect(() => lifecyclePlan(join(newRepository(), "uninstalled"))).toThrow("run orly init");
|
|
65
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readConfigSync, type RepoConfig } from "../config";
|
|
2
|
+
import { OrlyError } from "../model";
|
|
3
|
+
import { DISABLED_MODE } from "./config";
|
|
4
|
+
|
|
5
|
+
export const CONFORM_TIER = "conform";
|
|
6
|
+
export const FAST_TIER = "fast";
|
|
7
|
+
export const ALL_TIER = "all";
|
|
8
|
+
const COMMANDER = "commander";
|
|
9
|
+
const LOCAL = "local";
|
|
10
|
+
const VERIFY_PREFIX = "verify.";
|
|
11
|
+
const UNIT = "verify.unit";
|
|
12
|
+
export const CONDITIONAL_LANES = ["verify.integration", "verify.memory"];
|
|
13
|
+
export type CommandTier = typeof CONFORM_TIER | typeof FAST_TIER | typeof ALL_TIER;
|
|
14
|
+
|
|
15
|
+
// Used by both inspection and the actual gates so their command sets agree.
|
|
16
|
+
export function selectedCommands(commands: RepoConfig["commands"], tier: CommandTier): string[] {
|
|
17
|
+
return Object.keys(commands).filter((key) => {
|
|
18
|
+
if (tier === CONFORM_TIER) return key === CONFORM_TIER;
|
|
19
|
+
if (!key.startsWith(VERIFY_PREFIX)) return false;
|
|
20
|
+
return tier === ALL_TIER || (key !== UNIT && !CONDITIONAL_LANES.includes(key));
|
|
21
|
+
}).sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function lifecyclePlan(root: string) {
|
|
25
|
+
const config = readConfigSync(root);
|
|
26
|
+
if (!config) throw new OrlyError("lifecycle inspection requires repository configuration; run orly init");
|
|
27
|
+
const commands = (tier: CommandTier) => selectedCommands(config.commands, tier).map((lane) => ({
|
|
28
|
+
lane, argv: config.commands[lane], location: LOCAL, conditionalOnCode: CONDITIONAL_LANES.includes(lane),
|
|
29
|
+
}));
|
|
30
|
+
return {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
remote: { mode: DISABLED_MODE, launcher: config.execution?.remote.launcher ?? null, evidence: null, launches: 0 },
|
|
33
|
+
stages: [
|
|
34
|
+
stage("CHORE(open)", COMMANDER, "Create the scoped stream and record the comparison revision.", "Spec, branch and workspace; commander removes the owned workspace after landing."),
|
|
35
|
+
stage("PLAN", COMMANDER, "Read requirements and triggered rules; resolve scope and required evidence.", "Spec and agent context; no build prescribed."),
|
|
36
|
+
stage("EXECUTE", COMMANDER, "Implement and test the current Section.", "Source and tool-owned outputs; declared tools own their cleanup."),
|
|
37
|
+
{ ...stage("CONFORM", "orly gate work", "Run declared conformity commands.", "Command-owned caches and outputs."), commands: commands(CONFORM_TIER) },
|
|
38
|
+
{ ...stage("VERIFY", "orly gate verify / orly gate pr", "Push runs fast checks; the pull-request boundary runs every verification lane.", "Test outputs and comparison workspace; commander removes its baseline workspace."), pushCommands: commands(FAST_TIER), boundaryCommands: commands(ALL_TIER) },
|
|
39
|
+
stage("REVIEW", COMMANDER, "Run required adversarial review and resolve findings.", "Review receipts and bounded optional judgment replay."),
|
|
40
|
+
stage("DOCUMENT", COMMANDER, "Update affected user instructions and evidence.", "Tracked documentation."),
|
|
41
|
+
stage("COMMIT", "Git hooks", "Run installed work hook; push runs installed verify hook.", "Git objects; repository hooks may declare additional checks."),
|
|
42
|
+
stage("CHORE(close)", "commander / orly gate pr", "Close the spec only when scope and required evidence are complete.", "Committed spec and pull-request evidence."),
|
|
43
|
+
stage("LAND", COMMANDER, "After authorized merge, update the default branch and remove owned stream resources.", "Repository-specific shutdown and worktree cleanup."),
|
|
44
|
+
],
|
|
45
|
+
limits: "Inspection executes no commands. Command disk usage is tool-owned and is not measured or bounded by this plan. Remote delegation is disabled and supplies no check result.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stage(name: string, executor: string, work: string, resources: string) {
|
|
50
|
+
return { name, executor, work, resources, location: LOCAL };
|
|
51
|
+
}
|
package/src/gates.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type GateName = (typeof GATE_ORDER)[number];
|
|
|
11
11
|
|
|
12
12
|
const PIPE_OUTPUT = "pipe";
|
|
13
13
|
const GIT = "git";
|
|
14
|
+
const CURRENT_BRANCH_ARGUMENTS = ["rev-parse", "--abbrev-ref", "HEAD"];
|
|
14
15
|
const DOCS_DIRECTORY = "docs";
|
|
15
16
|
const ACTIVE_DIRECTORY = "active";
|
|
16
17
|
const DONE_DIRECTORY = "done";
|
|
@@ -23,7 +24,7 @@ const UTF8 = "utf8";
|
|
|
23
24
|
const PROTOTYPE_PATTERN = /^v\d+(\.\d+)*$/;
|
|
24
25
|
const MARKDOWN_EXTENSION = ".md";
|
|
25
26
|
const NEWLINE = "\n";
|
|
26
|
-
const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s
|
|
27
|
+
const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s*(?:`([^`]+)`|([^\s`]+))\s*$/;
|
|
27
28
|
const SPEC_IDENTIFIER_PATTERN = /^(M\d+_\d+)(?:_|\.md$)/;
|
|
28
29
|
// Strict trailer shape: "Orly-Override: <criterion> (<reason>)". A trailer
|
|
29
30
|
// that does not parse is not an override — the gate stays red rather than
|
|
@@ -39,7 +40,7 @@ export type GateReport = {
|
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
export type Override = { criterion: string; reason: string };
|
|
42
|
-
type
|
|
43
|
+
type SpecMetadata = {
|
|
43
44
|
path: string;
|
|
44
45
|
identifier: string | undefined;
|
|
45
46
|
branch: string | undefined;
|
|
@@ -105,11 +106,36 @@ export function branchOverrides(root: string): Override[] {
|
|
|
105
106
|
return overrides;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// One stream per worktree, and a fold is not a second stream. Extra specs in
|
|
110
|
+
// active/ are allowed only when each names the one non-folded owner through
|
|
111
|
+
// Folded-into — the same relation closedSpecPath enforces in done/. Without
|
|
112
|
+
// this, folding a workstream into an open stream was impossible during the
|
|
113
|
+
// work and legal only after the close, which is the wrong way round: the fold
|
|
114
|
+
// is decided when the scope is, not when the spec moves. Specs explicitly
|
|
115
|
+
// owned by another branch are excluded; undeclared ownership stays visible.
|
|
108
116
|
export function activeSpecPath(root: string): string | undefined {
|
|
109
|
-
const
|
|
117
|
+
const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
|
|
118
|
+
const specs = specPathsUnder(root, ACTIVE_DIRECTORY).map(specMetadata)
|
|
119
|
+
.filter((spec): spec is SpecMetadata => spec !== undefined && (spec.branch === undefined || spec.branch === branch));
|
|
110
120
|
if (specs.length === 0) return undefined;
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
return owningSpec(specs, "active spec", "active specs").path;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The fold relation, shared by both discovery paths: exactly one non-folded
|
|
125
|
+
// owner, every other spec naming it, and no spec naming itself.
|
|
126
|
+
function owningSpec(specs: SpecMetadata[], one: string, many: string): SpecMetadata {
|
|
127
|
+
const owners = specs.filter((spec) => spec.foldedInto === undefined);
|
|
128
|
+
if (owners.length === 0) throw new OrlyError(`every ${one} is folded — one owning stream is required:${NEWLINE}${specs.map((spec) => spec.path).join(NEWLINE)}`);
|
|
129
|
+
if (owners.length > 1) throw new OrlyError(`more than one ${one} — one stream per worktree:${NEWLINE}${owners.map((spec) => spec.path).join(NEWLINE)}`);
|
|
130
|
+
const owner = owners[0];
|
|
131
|
+
if (!owner) throw new OrlyError(`${one} ownership could not be resolved`);
|
|
132
|
+
const folded = specs.filter((spec) => spec.foldedInto !== undefined);
|
|
133
|
+
if (folded.length > 0 && !owner.identifier) throw new OrlyError(`the owning ${one} has no milestone and workstream identifier: ${owner.path}`);
|
|
134
|
+
const selfFolds = folded.filter((spec) => spec.identifier === spec.foldedInto);
|
|
135
|
+
if (selfFolds.length > 0) throw new OrlyError(`folded ${many} cannot fold into themselves:${NEWLINE}${selfFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
136
|
+
const invalidFolds = folded.filter((spec) => spec.foldedInto !== owner.identifier);
|
|
137
|
+
if (invalidFolds.length > 0) throw new OrlyError(`folded ${many} must name their owner ${owner.identifier}:${NEWLINE}${invalidFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
138
|
+
return owner;
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
// Closed-spec follow-through: CHORE(close) moves the spec to done/, and the
|
|
@@ -117,24 +143,13 @@ export function activeSpecPath(root: string): string | undefined {
|
|
|
117
143
|
// be end-state theater. Discovery matches done/ specs whose Branch: header
|
|
118
144
|
// names the current branch; default branches never match a stream's spec.
|
|
119
145
|
export function closedSpecPath(root: string): string | undefined {
|
|
120
|
-
const branch = gitOutput(root,
|
|
146
|
+
const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
|
|
121
147
|
if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
|
|
122
148
|
const specs = specPathsUnder(root, DONE_DIRECTORY)
|
|
123
|
-
.map(
|
|
124
|
-
.filter((spec): spec is
|
|
149
|
+
.map(specMetadata)
|
|
150
|
+
.filter((spec): spec is SpecMetadata => spec !== undefined && spec.branch === branch);
|
|
125
151
|
if (specs.length === 0) return undefined;
|
|
126
|
-
|
|
127
|
-
if (owners.length === 0) throw new OrlyError(`every done/ spec naming branch ${branch} is folded — one owning stream is required:${NEWLINE}${specs.map((spec) => spec.path).join(NEWLINE)}`);
|
|
128
|
-
if (owners.length > 1) throw new OrlyError(`more than one done/ spec names branch ${branch} — one stream per worktree:${NEWLINE}${owners.map((spec) => spec.path).join(NEWLINE)}`);
|
|
129
|
-
const owner = owners[0];
|
|
130
|
-
if (!owner) throw new OrlyError(`done/ spec ownership could not be resolved for branch ${branch}`);
|
|
131
|
-
const folded = specs.filter((spec) => spec.foldedInto !== undefined);
|
|
132
|
-
if (folded.length > 0 && !owner.identifier) throw new OrlyError(`the owning done/ spec for branch ${branch} has no milestone and workstream identifier: ${owner.path}`);
|
|
133
|
-
const selfFolds = folded.filter((spec) => spec.identifier === spec.foldedInto);
|
|
134
|
-
if (selfFolds.length > 0) throw new OrlyError(`folded done/ specs naming branch ${branch} cannot fold into themselves:${NEWLINE}${selfFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
135
|
-
const invalidFolds = folded.filter((spec) => spec.foldedInto !== owner.identifier);
|
|
136
|
-
if (invalidFolds.length > 0) throw new OrlyError(`folded done/ specs naming branch ${branch} must name their owner ${owner.identifier}:${NEWLINE}${invalidFolds.map((spec) => spec.path).join(NEWLINE)}`);
|
|
137
|
-
return owner.path;
|
|
152
|
+
return owningSpec(specs, `done/ spec naming branch ${branch}`, `done/ specs naming branch ${branch}`).path;
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
// An in-flight (active/) spec wins; otherwise the branch's closed spec gates.
|
|
@@ -145,9 +160,9 @@ export function specPathFor(root: string): { path: string; closed: boolean } | u
|
|
|
145
160
|
return closed ? { path: closed, closed: true } : undefined;
|
|
146
161
|
}
|
|
147
162
|
|
|
148
|
-
function
|
|
163
|
+
function specMetadata(path: string): SpecMetadata | undefined {
|
|
149
164
|
try {
|
|
150
|
-
const metadata:
|
|
165
|
+
const metadata: SpecMetadata = {
|
|
151
166
|
path,
|
|
152
167
|
identifier: basename(path).match(SPEC_IDENTIFIER_PATTERN)?.[1],
|
|
153
168
|
branch: undefined,
|
|
@@ -156,8 +171,8 @@ function closedSpec(path: string): ClosedSpec | undefined {
|
|
|
156
171
|
for (const line of readFileSync(path, UTF8).split(/\r?\n/)) {
|
|
157
172
|
if (line.startsWith("## ")) break;
|
|
158
173
|
const match = line.match(MARKDOWN_HEADER_TOKEN_PATTERN);
|
|
159
|
-
if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2];
|
|
160
|
-
if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2];
|
|
174
|
+
if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2] ?? match[3];
|
|
175
|
+
if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2] ?? match[3];
|
|
161
176
|
}
|
|
162
177
|
return metadata;
|
|
163
178
|
} catch {
|