@agentsfleet/orly 0.10.14 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +65 -29
  4. package/SOUL.md +13 -23
  5. package/audits/agents-md.md +67 -57
  6. package/audits/deinit-pairs.sh +13 -18
  7. package/audits/design-tokens.sh +9 -23
  8. package/audits/doc-read.sh +10 -33
  9. package/audits/doc-reads.ts +41 -0
  10. package/audits/error-codes.sh +18 -25
  11. package/audits/logging-rust.awk +145 -0
  12. package/audits/logging.sh +10 -166
  13. package/audits/msid-ui.sh +45 -18
  14. package/audits/rule-ledger-lib.sh +9 -6
  15. package/audits/rust-error.sh +20 -15
  16. package/audits/scope.sh +88 -0
  17. package/audits/spec-template.sh +2 -2
  18. package/audits/spec-template.ts +1 -1
  19. package/audits/sql-mod.sh +11 -14
  20. package/audits/ufs-strings.awk +164 -0
  21. package/audits/ufs.sh +10 -188
  22. package/core/operating-model.md +8 -8
  23. package/dispatch/edit_rules.md +8 -6
  24. package/dispatch/lib.sh +41 -26
  25. package/dispatch/lifecycle.md +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_VOICE.md +11 -2
  40. package/docs/EXECUTE_DOC_READS.md +10 -10
  41. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  42. package/docs/JUDGMENTS.md +153 -0
  43. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  44. package/docs/LOGGING_STANDARD.md +39 -19
  45. package/docs/ORLY_ARCHITECTURE.md +77 -6
  46. package/docs/RELEASE_TEMPLATE.md +21 -5
  47. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  48. package/docs/RULE_ENFORCEMENT.md +6 -6
  49. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  50. package/docs/TEMPLATE.md +4 -4
  51. package/docs/VERIFY_TIERS.md +2 -2
  52. package/docs/greptile-learnings/RULES.md +81 -26
  53. package/llms.txt +37 -34
  54. package/package.json +6 -1
  55. package/packs/language/go/rules.md +1 -1
  56. package/packs/language/python/rules.md +1 -1
  57. package/packs/language/rust/rules.md +17 -1
  58. package/packs/language/shell/rules.md +1 -1
  59. package/registry.json +22 -7
  60. package/schemas/profile.schema.json +48 -5
  61. package/skills/orly-babysit-prs/SKILL.md +87 -34
  62. package/skills/orly-spec-new/SKILL.md +19 -6
  63. package/skills/orly-write-integration-test/SKILL.md +4 -4
  64. package/skills/orly-write-unit-test/SKILL.md +10 -1
  65. package/src/cli.test.ts +2 -2
  66. package/src/cli.ts +19 -75
  67. package/src/cli_help.ts +69 -0
  68. package/src/command_limits.ts +20 -0
  69. package/src/command_process.test.ts +109 -0
  70. package/src/command_process.ts +88 -0
  71. package/src/command_runner.test.ts +88 -0
  72. package/src/command_runner.ts +33 -0
  73. package/src/comprehension_cache.test.ts +123 -0
  74. package/src/config.test.ts +5 -4
  75. package/src/config.ts +26 -164
  76. package/src/config_discovery.ts +159 -0
  77. package/src/config_validation.test.ts +56 -0
  78. package/src/config_validation.ts +25 -0
  79. package/src/criteria.test.ts +1 -1
  80. package/src/criteria.ts +10 -26
  81. package/src/criteria_spec.ts +12 -20
  82. package/src/criteria_support.ts +2 -19
  83. package/src/doc_reads.test.ts +42 -0
  84. package/src/doc_rules.ts +2 -2
  85. package/src/evaluation_resources.test.ts +64 -0
  86. package/src/execution/config.ts +22 -0
  87. package/src/execution/plan.test.ts +65 -0
  88. package/src/execution/plan.ts +51 -0
  89. package/src/gates.ts +9 -6
  90. package/src/gates_spec.test.ts +52 -6
  91. package/src/gates_test_support.ts +9 -7
  92. package/src/install.test.ts +38 -222
  93. package/src/install.ts +117 -308
  94. package/src/install_loaders.test.ts +203 -0
  95. package/src/install_nested.test.ts +2 -2
  96. package/src/install_packs.test.ts +66 -6
  97. package/src/installation/hooks.ts +63 -0
  98. package/src/installation/lock.test.ts +97 -0
  99. package/src/installation/lock.ts +56 -0
  100. package/src/installation/migration.test.ts +212 -0
  101. package/src/installation/migration.ts +65 -0
  102. package/src/installation/plan.ts +96 -0
  103. package/src/installation/transaction.ts +126 -0
  104. package/src/installation_preservation.test.ts +48 -0
  105. package/src/judgments/advice.test.ts +38 -0
  106. package/src/judgments/advice.ts +32 -0
  107. package/src/judgments/command.integration.test.ts +47 -0
  108. package/src/judgments/command.ts +90 -0
  109. package/src/judgments/constants.ts +55 -0
  110. package/src/judgments/evidence.test.ts +48 -0
  111. package/src/judgments/evidence.ts +34 -0
  112. package/src/judgments/files.test.ts +62 -0
  113. package/src/judgments/files.ts +73 -0
  114. package/src/judgments/questions.test.ts +19 -0
  115. package/src/judgments/questions.ts +85 -0
  116. package/src/judgments/replay.test.ts +43 -0
  117. package/src/judgments/replay.ts +66 -0
  118. package/src/judgments/runner.integration.test.ts +165 -0
  119. package/src/judgments/runner.ts +97 -0
  120. package/src/judgments/scan.test.ts +19 -0
  121. package/src/judgments/scan.ts +40 -0
  122. package/src/judgments/syntax.test.ts +115 -0
  123. package/src/judgments/syntax.ts +71 -0
  124. package/src/judgments/syntax_worker.ts +70 -0
  125. package/src/judgments/test_support.ts +60 -0
  126. package/src/judgments/transport.integration.test.ts +63 -0
  127. package/src/judgments/transport.ts +35 -0
  128. package/src/judgments/types.test.ts +43 -0
  129. package/src/judgments/types.ts +66 -0
  130. package/src/judgments/wire.test.ts +48 -0
  131. package/src/judgments/wire.ts +40 -0
  132. package/src/loaders.test.ts +13 -16
  133. package/src/loaders.ts +28 -10
  134. package/src/model.test.ts +20 -3
  135. package/src/model.ts +7 -18
  136. package/src/pack_hygiene.test.ts +42 -1
  137. package/src/pack_hygiene.ts +18 -8
  138. package/src/references.ts +15 -7
  139. package/src/release_inventory.test.ts +80 -0
  140. package/src/render.test.ts +1 -1
  141. package/src/render.ts +3 -8
  142. package/src/required_scanner.test.ts +36 -0
  143. package/src/review_poll.test.ts +158 -0
  144. package/src/scanner_scope.test.ts +202 -0
  145. package/src/setup.test.ts +9 -6
  146. package/src/spec_baseline.test.ts +1 -1
  147. package/src/spec_completion.test.ts +74 -0
  148. package/src/spec_obligations.ts +64 -0
  149. package/src/spec_rehearsal.test.ts +182 -0
  150. package/src/spec_template_io.test.ts +11 -9
  151. package/src/surfaces.test.ts +2 -0
  152. package/src/surfaces.ts +1 -1
  153. package/src/validation.ts +7 -4
  154. package/src/verify.test.ts +18 -2
  155. package/src/verify.ts +7 -3
@@ -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, "--file", context.specPath ?? ""]));
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 = specLines(context).filter((line) => line.trimStart().startsWith(DIMENSION_PREFIX));
66
- const open = dimensions.filter((line) => !line.includes(DONE_MARKER));
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}: ${dimensionLabels(open)}`,
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 claims = specLines(context).filter((line) => DEFERRAL_CLAIM.test(line) && !line.includes(INDY_ACK));
138
- if (claims.length === 0) return { ok: true, detail: "no deferral claims" };
139
- const acked = specLines(context).some((line) => line.includes(INDY_ACK));
140
- return acked
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 specLines(context).some((line) => line.replaceAll("*", "").replace(/\s+/g, " ").includes(STATUS_DONE));
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);
@@ -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 = ".oracle/orly.json";
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: `.oracle/orly.json`
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
@@ -110,10 +111,12 @@ export function branchOverrides(root: string): Override[] {
110
111
  // Folded-into — the same relation closedSpecPath enforces in done/. Without
111
112
  // this, folding a workstream into an open stream was impossible during the
112
113
  // work and legal only after the close, which is the wrong way round: the fold
113
- // is decided when the scope is, not when the spec moves.
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.
114
116
  export function activeSpecPath(root: string): string | undefined {
117
+ const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
115
118
  const specs = specPathsUnder(root, ACTIVE_DIRECTORY).map(specMetadata)
116
- .filter((spec): spec is SpecMetadata => spec !== undefined);
119
+ .filter((spec): spec is SpecMetadata => spec !== undefined && (spec.branch === undefined || spec.branch === branch));
117
120
  if (specs.length === 0) return undefined;
118
121
  return owningSpec(specs, "active spec", "active specs").path;
119
122
  }
@@ -140,7 +143,7 @@ function owningSpec(specs: SpecMetadata[], one: string, many: string): SpecMetad
140
143
  // be end-state theater. Discovery matches done/ specs whose Branch: header
141
144
  // names the current branch; default branches never match a stream's spec.
142
145
  export function closedSpecPath(root: string): string | undefined {
143
- const branch = gitOutput(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
146
+ const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
144
147
  if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
145
148
  const specs = specPathsUnder(root, DONE_DIRECTORY)
146
149
  .map(specMetadata)
@@ -168,8 +171,8 @@ function specMetadata(path: string): SpecMetadata | undefined {
168
171
  for (const line of readFileSync(path, UTF8).split(/\r?\n/)) {
169
172
  if (line.startsWith("## ")) break;
170
173
  const match = line.match(MARKDOWN_HEADER_TOKEN_PATTERN);
171
- if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2];
172
- 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];
173
176
  }
174
177
  return metadata;
175
178
  } catch {
@@ -2,13 +2,18 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- import { activeSpecPath, closedSpecPath, runGate } from "./gates";
5
+ import { activeSpecPath, closedSpecPath, runGate, specPathFor } from "./gates";
6
6
  import {
7
7
  cleanupTemporaryDirectories, closedSpecRepository, fixtureRegistry, git, modelFor,
8
8
  newRepository, newSpecRepository, orly, SPEC_RELATIVE, specFixture,
9
9
  } from "./gates_test_support";
10
10
 
11
11
  const BASELINE_FIXTURE = "**Test Baseline:** unit=0 integration=0";
12
+ const OTHER_ACTIVE_SPEC = "docs/v1/active/M100_001_P2_CLI_OTHER.md";
13
+ const CLOSED_SPEC = "docs/v1/done/M99_001_P2_CLI_FIXTURE.md";
14
+ const CLOSED_BRANCH = "feat/closed-owner";
15
+ const OTHER_BRANCH = "feat/other";
16
+ const ACTIVE_SPECS_DIRECTORY = "docs/v1/active";
12
17
 
13
18
  afterEach(cleanupTemporaryDirectories);
14
19
 
@@ -31,6 +36,47 @@ describe("spec discovery", () => {
31
36
  expect(activeSpecPath(project)).toContain("docs/v0.9.2/active");
32
37
  });
33
38
 
39
+ test.each([false, true])("active ownership follows the branch with quoted header %s", async (quoted) => {
40
+ const project = newSpecRepository();
41
+ const branch = "feat/owned";
42
+ git(project, "checkout", "-q", "-b", branch);
43
+ const header = quoted ? `**Branch:** \`${branch}\`` : `**Branch:** ${branch}`;
44
+ await Bun.write(join(project, SPEC_RELATIVE), specFixture(undefined, undefined, [header]));
45
+ await Bun.write(join(project, OTHER_ACTIVE_SPEC), specFixture(undefined, OTHER_BRANCH));
46
+
47
+ expect(activeSpecPath(project)).toBe(join(project, SPEC_RELATIVE));
48
+ });
49
+
50
+ test.each([false, true])("an unrelated active spec cannot hide a closed owner with quoted header %s", async (quoted) => {
51
+ const branch = CLOSED_BRANCH;
52
+ const project = closedSpecRepository(branch);
53
+ const path = join(project, CLOSED_SPEC);
54
+ const header = quoted ? `**Branch:** \`${branch}\`` : `**Branch:** ${branch}`;
55
+ await Bun.write(path, specFixture("DONE", undefined, [header]));
56
+ mkdirSync(join(project, ACTIVE_SPECS_DIRECTORY), { recursive: true });
57
+ await Bun.write(join(project, OTHER_ACTIVE_SPEC), specFixture(undefined, OTHER_BRANCH));
58
+
59
+ expect(specPathFor(project)).toEqual({ path, closed: true });
60
+ });
61
+
62
+ test("an active spec without branch ownership still gates", async () => {
63
+ const project = closedSpecRepository(CLOSED_BRANCH);
64
+ mkdirSync(join(project, ACTIVE_SPECS_DIRECTORY), { recursive: true });
65
+ await Bun.write(join(project, SPEC_RELATIVE), specFixture());
66
+
67
+ expect(specPathFor(project)).toEqual({ path: join(project, SPEC_RELATIVE), closed: false });
68
+ });
69
+
70
+ test("two active owners on the current branch remain an error", async () => {
71
+ const project = newSpecRepository();
72
+ const branch = "feat/shared-owners";
73
+ git(project, "checkout", "-q", "-b", branch);
74
+ await Bun.write(join(project, SPEC_RELATIVE), specFixture(undefined, branch));
75
+ await Bun.write(join(project, OTHER_ACTIVE_SPEC), specFixture(undefined, branch));
76
+
77
+ expect(() => activeSpecPath(project)).toThrow("one stream per worktree");
78
+ });
79
+
34
80
  test("two active specs are a hard error — one stream per worktree", () => {
35
81
  const project = newSpecRepository();
36
82
  mkdirSync(join(project, "docs/v2/active"), { recursive: true });
@@ -39,12 +85,12 @@ describe("spec discovery", () => {
39
85
  expect(() => activeSpecPath(project)).toThrow("one stream per worktree");
40
86
  });
41
87
 
42
- test("a folded active spec yields ownership to the spec it names", async () => {
88
+ test.each([false, true])("a folded active spec yields ownership with quoted header %s", async (quoted) => {
43
89
  const project = newSpecRepository();
44
90
  mkdirSync(join(project, "docs/v2/active"), { recursive: true });
45
91
  await Bun.write(
46
92
  join(project, "docs/v2/active/M100_001_P2_CLI_FOLDED.md"),
47
- specFixture(undefined, undefined, ["**Folded-into:** `M99_001`"]),
93
+ specFixture(undefined, undefined, [quoted ? "**Folded-into:** `M99_001`" : "**Folded-into:** M99_001"]),
48
94
  );
49
95
 
50
96
  expect(activeSpecPath(project)).toEndWith("M99_001_P2_CLI_FIXTURE.md");
@@ -109,12 +155,12 @@ describe("closed-spec follow-through", () => {
109
155
  expect(() => closedSpecPath(project)).toThrow("one stream per worktree");
110
156
  });
111
157
 
112
- test("a folded spec yields ownership to the spec it names", async () => {
158
+ test.each([false, true])("a folded closed spec yields ownership with quoted header %s", async (quoted) => {
113
159
  const project = closedSpecRepository("feat/shared");
114
160
  mkdirSync(join(project, "docs/v2/done"), { recursive: true });
115
161
  await Bun.write(
116
162
  join(project, "docs/v2/done/M100_001_P2_CLI_FOLDED.md"),
117
- specFixture("DONE", "feat/shared", ["**Folded-into:** `M99_001`"]),
163
+ specFixture("DONE", "feat/shared", [quoted ? "**Folded-into:** `M99_001`" : "**Folded-into:** M99_001"]),
118
164
  );
119
165
 
120
166
  expect(closedSpecPath(project)).toEndWith("M99_001_P2_CLI_FIXTURE.md");
@@ -261,7 +307,7 @@ describe("closed-spec follow-through", () => {
261
307
 
262
308
  const acked = closedSpecRepository("feat/defer", [
263
309
  "- Dimension 1.2 was deferred to follow-up",
264
- '> Indy (2026-08-11 09:00): "defer 1.2, ship the rest" — context: fixture',
310
+ '## Discovery\n\n> Indy (2026-08-11 09:00): "defer 1.2, ship the rest" — context: fixture',
265
311
  ]);
266
312
  const ackedModel = await modelFor(acked);
267
313
  expect(runGate(ackedModel, acked, "pr").results.find((result) => result.name === "spec.deferrals")?.ok).toBeTrue();
@@ -23,9 +23,11 @@ const PIPE_OUTPUT = "pipe";
23
23
  const TRUE_COMMAND = "true";
24
24
  const ACCEPTANCE_RUBRIC_HEADING = "Acceptance Rubric";
25
25
  const FIXTURE = "fixture";
26
- const AUDITS_DIR = "audits";
26
+ const AUDITS_DIR = ".orly/audits";
27
27
  const SPEC_GATE_SCRIPT = `${AUDITS_DIR}/spec-template.sh`;
28
28
  const SPEC_GATE_CONTENT = `${AUDITS_DIR}/spec-template.ts`;
29
+ const SOURCE_SPEC_GATE = "audits/spec-template.sh";
30
+ const SOURCE_SPEC_CONTENT = "audits/spec-template.ts";
29
31
 
30
32
  export const ROOT = resolve(import.meta.dir, "..");
31
33
  export const SPEC_RELATIVE = "docs/v1/active/M99_001_P2_CLI_FIXTURE.md";
@@ -66,13 +68,13 @@ export function fixtureRegistry(project: string): string {
66
68
  return root;
67
69
  }
68
70
 
69
- // The gate resolves commands from `.oracle/orly.json` in the repository under
71
+ // The gate resolves commands from `.orly/orly.json` in the repository under
70
72
  // test, so every fixture repository carries one.
71
73
  export function writeFixtureConfig(project: string, surfaces?: { user: string[]; docs: string[] }, extraCommands?: Record<string, string[][]>): void {
72
74
  const config: Record<string, unknown> = structuredClone(FIXTURE_CONFIG);
73
75
  if (surfaces) config.surfaces = surfaces;
74
76
  if (extraCommands) config.commands = { ...(config.commands as Record<string, unknown>), ...extraCommands };
75
- mkdirSync(join(project, ".oracle"), { recursive: true });
77
+ mkdirSync(join(project, ".orly"), { recursive: true });
76
78
  writeFileSync(join(project, CONFIG_PATH), JSON.stringify(config, undefined, 2));
77
79
  // Committed, exactly as a real repository carries it: a linked worktree gets
78
80
  // its rules config from the checkout, and an uncommitted one would also make
@@ -109,8 +111,8 @@ export function closedSpecRepository(branch: string, extraLines: string[] = []):
109
111
  git(project, "checkout", QUIET, "-b", branch);
110
112
  mkdirSync(join(project, "docs/v1/done"), { recursive: true });
111
113
  mkdirSync(join(project, AUDITS_DIR), { recursive: true });
112
- copyFileSync(join(ROOT, SPEC_GATE_SCRIPT), join(project, SPEC_GATE_SCRIPT));
113
- copyFileSync(join(ROOT, SPEC_GATE_CONTENT), join(project, SPEC_GATE_CONTENT));
114
+ copyFileSync(join(ROOT, SOURCE_SPEC_GATE), join(project, SPEC_GATE_SCRIPT));
115
+ copyFileSync(join(ROOT, SOURCE_SPEC_CONTENT), join(project, SPEC_GATE_CONTENT));
114
116
  Bun.write(join(project, "docs/v1/done/M99_001_P2_CLI_FIXTURE.md"), specFixture("DONE", branch, [
115
117
  `**Baseline revision:** ${gitOutput(project, REV_PARSE, HEAD)}`, BASELINE_EVIDENCE, ...extraLines,
116
118
  ]));
@@ -123,8 +125,8 @@ export function newSpecRepository(): string {
123
125
  const project = newRepository();
124
126
  mkdirSync(join(project, "docs/v1/active"), { recursive: true });
125
127
  mkdirSync(join(project, AUDITS_DIR), { recursive: true });
126
- copyFileSync(join(ROOT, SPEC_GATE_SCRIPT), join(project, SPEC_GATE_SCRIPT));
127
- copyFileSync(join(ROOT, SPEC_GATE_CONTENT), join(project, SPEC_GATE_CONTENT));
128
+ copyFileSync(join(ROOT, SOURCE_SPEC_GATE), join(project, SPEC_GATE_SCRIPT));
129
+ copyFileSync(join(ROOT, SOURCE_SPEC_CONTENT), join(project, SPEC_GATE_CONTENT));
128
130
  Bun.write(join(project, SPEC_RELATIVE), specFixture(IN_PROGRESS, undefined, [
129
131
  `**Baseline revision:** ${gitOutput(project, REV_PARSE, HEAD)}`, BASELINE_EVIDENCE,
130
132
  ]));