@agentsfleet/orly 0.10.13 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +65 -29
  4. package/SOUL.md +13 -23
  5. package/audits/agents-md.md +67 -57
  6. package/audits/deinit-pairs.sh +13 -18
  7. package/audits/design-tokens.sh +9 -23
  8. package/audits/doc-read.sh +10 -33
  9. package/audits/doc-reads.ts +41 -0
  10. package/audits/error-codes.sh +18 -25
  11. package/audits/logging-rust.awk +145 -0
  12. package/audits/logging.sh +10 -166
  13. package/audits/msid-ui.sh +45 -18
  14. package/audits/rule-ledger-lib.sh +9 -6
  15. package/audits/rust-error.sh +20 -15
  16. package/audits/scope.sh +88 -0
  17. package/audits/spec-template.sh +2 -2
  18. package/audits/spec-template.ts +1 -1
  19. package/audits/sql-mod.sh +11 -14
  20. package/audits/ufs-strings.awk +164 -0
  21. package/audits/ufs.sh +10 -188
  22. package/core/operating-model.md +8 -8
  23. package/dispatch/edit_rules.md +8 -6
  24. package/dispatch/lib.sh +41 -26
  25. package/dispatch/lifecycle.md +18 -6
  26. package/dispatch/name_architecture.md +8 -3
  27. package/dispatch/verify.md +1 -1
  28. package/dispatch/write_any.md +10 -3
  29. package/dispatch/write_changelog.md +7 -4
  30. package/dispatch/write_go.md +1 -1
  31. package/dispatch/write_http.md +5 -7
  32. package/dispatch/write_python.md +1 -1
  33. package/dispatch/write_rust.md +17 -1
  34. package/dispatch/write_shell.md +1 -1
  35. package/dispatch/write_spec.md +3 -1
  36. package/dispatch/write_sql.md +28 -6
  37. package/dispatch/write_ts_adhere_bun.md +11 -0
  38. package/dispatch/write_zig.md +53 -21
  39. package/docs/CHANGELOG_VOICE.md +11 -2
  40. package/docs/EXECUTE_DOC_READS.md +10 -10
  41. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  42. package/docs/JUDGMENTS.md +153 -0
  43. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  44. package/docs/LOGGING_STANDARD.md +39 -19
  45. package/docs/ORLY_ARCHITECTURE.md +77 -6
  46. package/docs/RELEASE_TEMPLATE.md +21 -5
  47. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  48. package/docs/RULE_ENFORCEMENT.md +6 -6
  49. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  50. package/docs/TEMPLATE.md +4 -4
  51. package/docs/VERIFY_TIERS.md +2 -2
  52. package/docs/greptile-learnings/RULES.md +81 -26
  53. package/llms.txt +37 -34
  54. package/package.json +6 -1
  55. package/packs/language/go/rules.md +1 -1
  56. package/packs/language/python/rules.md +1 -1
  57. package/packs/language/rust/rules.md +17 -1
  58. package/packs/language/shell/rules.md +1 -1
  59. package/registry.json +22 -7
  60. package/schemas/profile.schema.json +48 -5
  61. package/skills/orly-babysit-prs/SKILL.md +87 -34
  62. package/skills/orly-spec-new/SKILL.md +19 -6
  63. package/skills/orly-write-integration-test/SKILL.md +4 -4
  64. package/skills/orly-write-unit-test/SKILL.md +10 -1
  65. package/src/cli.test.ts +2 -2
  66. package/src/cli.ts +19 -75
  67. package/src/cli_help.ts +69 -0
  68. package/src/command_limits.ts +20 -0
  69. package/src/command_process.test.ts +109 -0
  70. package/src/command_process.ts +88 -0
  71. package/src/command_runner.test.ts +88 -0
  72. package/src/command_runner.ts +33 -0
  73. package/src/comprehension_cache.test.ts +123 -0
  74. package/src/config.test.ts +5 -4
  75. package/src/config.ts +26 -164
  76. package/src/config_discovery.ts +159 -0
  77. package/src/config_validation.test.ts +56 -0
  78. package/src/config_validation.ts +25 -0
  79. package/src/criteria.test.ts +1 -1
  80. package/src/criteria.ts +10 -26
  81. package/src/criteria_spec.ts +12 -20
  82. package/src/criteria_support.ts +2 -19
  83. package/src/doc_reads.test.ts +42 -0
  84. package/src/doc_rules.ts +2 -2
  85. package/src/evaluation_resources.test.ts +64 -0
  86. package/src/execution/config.ts +22 -0
  87. package/src/execution/plan.test.ts +65 -0
  88. package/src/execution/plan.ts +51 -0
  89. package/src/gates.ts +39 -24
  90. package/src/gates_spec.test.ts +110 -4
  91. package/src/gates_test_support.ts +9 -7
  92. package/src/install.test.ts +38 -222
  93. package/src/install.ts +117 -308
  94. package/src/install_loaders.test.ts +203 -0
  95. package/src/install_nested.test.ts +2 -2
  96. package/src/install_packs.test.ts +66 -6
  97. package/src/installation/hooks.ts +63 -0
  98. package/src/installation/lock.test.ts +97 -0
  99. package/src/installation/lock.ts +56 -0
  100. package/src/installation/migration.test.ts +212 -0
  101. package/src/installation/migration.ts +65 -0
  102. package/src/installation/plan.ts +96 -0
  103. package/src/installation/transaction.ts +126 -0
  104. package/src/installation_preservation.test.ts +48 -0
  105. package/src/judgments/advice.test.ts +38 -0
  106. package/src/judgments/advice.ts +32 -0
  107. package/src/judgments/command.integration.test.ts +47 -0
  108. package/src/judgments/command.ts +90 -0
  109. package/src/judgments/constants.ts +55 -0
  110. package/src/judgments/evidence.test.ts +48 -0
  111. package/src/judgments/evidence.ts +34 -0
  112. package/src/judgments/files.test.ts +62 -0
  113. package/src/judgments/files.ts +73 -0
  114. package/src/judgments/questions.test.ts +19 -0
  115. package/src/judgments/questions.ts +85 -0
  116. package/src/judgments/replay.test.ts +43 -0
  117. package/src/judgments/replay.ts +66 -0
  118. package/src/judgments/runner.integration.test.ts +165 -0
  119. package/src/judgments/runner.ts +97 -0
  120. package/src/judgments/scan.test.ts +19 -0
  121. package/src/judgments/scan.ts +40 -0
  122. package/src/judgments/syntax.test.ts +115 -0
  123. package/src/judgments/syntax.ts +71 -0
  124. package/src/judgments/syntax_worker.ts +70 -0
  125. package/src/judgments/test_support.ts +60 -0
  126. package/src/judgments/transport.integration.test.ts +63 -0
  127. package/src/judgments/transport.ts +35 -0
  128. package/src/judgments/types.test.ts +43 -0
  129. package/src/judgments/types.ts +66 -0
  130. package/src/judgments/wire.test.ts +48 -0
  131. package/src/judgments/wire.ts +40 -0
  132. package/src/loaders.test.ts +13 -16
  133. package/src/loaders.ts +28 -10
  134. package/src/model.test.ts +20 -3
  135. package/src/model.ts +7 -18
  136. package/src/pack_hygiene.test.ts +42 -1
  137. package/src/pack_hygiene.ts +18 -8
  138. package/src/references.ts +15 -7
  139. package/src/release_inventory.test.ts +80 -0
  140. package/src/render.test.ts +1 -1
  141. package/src/render.ts +3 -8
  142. package/src/required_scanner.test.ts +36 -0
  143. package/src/review_poll.test.ts +158 -0
  144. package/src/scanner_scope.test.ts +202 -0
  145. package/src/setup.test.ts +9 -6
  146. package/src/spec_baseline.test.ts +1 -1
  147. package/src/spec_completion.test.ts +74 -0
  148. package/src/spec_obligations.ts +64 -0
  149. package/src/spec_rehearsal.test.ts +182 -0
  150. package/src/spec_template_io.test.ts +11 -9
  151. package/src/surfaces.test.ts +2 -0
  152. package/src/surfaces.ts +1 -1
  153. package/src/validation.ts +7 -4
  154. package/src/verify.test.ts +18 -2
  155. package/src/verify.ts +7 -3
@@ -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
@@ -39,7 +40,7 @@ export type GateReport = {
39
40
  };
40
41
 
41
42
  export type Override = { criterion: string; reason: string };
42
- type ClosedSpec = {
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 specs = specPathsUnder(root, ACTIVE_DIRECTORY);
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
- if (specs.length > 1) throw new OrlyError(`more than one active spec — one stream per worktree:${NEWLINE}${specs.join(NEWLINE)}`);
112
- return specs[0];
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, ["rev-parse", "--abbrev-ref", "HEAD"]);
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(closedSpec)
124
- .filter((spec): spec is ClosedSpec => spec !== undefined && spec.branch === branch);
149
+ .map(specMetadata)
150
+ .filter((spec): spec is SpecMetadata => spec !== undefined && spec.branch === branch);
125
151
  if (specs.length === 0) return undefined;
126
- const owners = specs.filter((spec) => spec.foldedInto === undefined);
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 closedSpec(path: string): ClosedSpec | undefined {
163
+ function specMetadata(path: string): SpecMetadata | undefined {
149
164
  try {
150
- const metadata: ClosedSpec = {
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 {