@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
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import { MAX_RESPONSE_BYTES } from "./constants";
4
+ import { choiceReply, TestProject } from "./test_support";
5
+ import { requestAdvice } from "./transport";
6
+
7
+ test("provider request crosses real HTTP with the selected request and no redirect", async () => {
8
+ using project = new TestProject();
9
+ const prepared = await project.prepare();
10
+ const received: string[] = [];
11
+ using server = Bun.serve({ port: 0, async fetch(request) {
12
+ received.push(await request.text());
13
+ expect(request.headers.get("authorization")).toBe("Bearer test-credential");
14
+ return Response.json(choiceReply());
15
+ } });
16
+ const reply = await requestAdvice(prepared, "test-credential", (request) => fetch(new Request(server.url, request)));
17
+ expect(reply).toEqual(choiceReply());
18
+ expect(received).toEqual([prepared.request]);
19
+ });
20
+
21
+ test("HTTP refusal never retries or echoes the response body", async () => {
22
+ using project = new TestProject();
23
+ const prepared = await project.prepare();
24
+ let attempts = 0;
25
+ using server = Bun.serve({ port: 0, fetch() { attempts++; return new Response("private-provider-detail", { status: 429 }); } });
26
+ const result = await requestAdvice(prepared, "test-credential", (request) => fetch(new Request(server.url, request))).catch((error: unknown) => error);
27
+ expect(String(result)).toContain("HTTP 429; no automatic retry");
28
+ expect(String(result)).not.toContain("private-provider-detail");
29
+ expect(attempts).toBe(1);
30
+ });
31
+
32
+ test("provider responses reject oversized and malformed JSON", async () => {
33
+ using project = new TestProject();
34
+ const prepared = await project.prepare();
35
+ const oversized = await requestAdvice(prepared, "unused", async () => new Response("x".repeat(MAX_RESPONSE_BYTES + 1))).catch((error: unknown) => error);
36
+ expect(String(oversized)).toContain("byte budget");
37
+ const malformed = await requestAdvice(prepared, "unused", async () => new Response("{")).catch((error: unknown) => error);
38
+ expect(String(malformed)).toContain("not valid JSON");
39
+ });
40
+
41
+ test("the deadline covers a stalled response body and cancels its reader", async () => {
42
+ using project = new TestProject();
43
+ const prepared = await project.prepare();
44
+ let canceled = false;
45
+ const stream = new ReadableStream<Uint8Array>({ cancel() { canceled = true; } });
46
+ const result = await requestAdvice(prepared, "unused", async () => new Response(stream), 1).catch((error: unknown) => error);
47
+ expect(String(result)).toContain("timed out and was canceled");
48
+ expect(canceled).toBe(true);
49
+ expect(stream.locked).toBe(false);
50
+ });
51
+
52
+ test("transport failure hides credentials and cancels the request", async () => {
53
+ using project = new TestProject();
54
+ const prepared = await project.prepare();
55
+ let observed: AbortSignal | undefined;
56
+ const result = await requestAdvice(prepared, "private-test-value", async (request) => {
57
+ observed = request.signal;
58
+ throw new Error("private-test-value");
59
+ }).catch((error: unknown) => error);
60
+ expect(String(result)).toContain("Provider transport failed");
61
+ expect(String(result)).not.toContain("private-test-value");
62
+ expect(observed?.aborted).toBe(true);
63
+ });
@@ -0,0 +1,35 @@
1
+ import type { Prepared } from "./types";
2
+
3
+ import { OrlyError } from "../model";
4
+ import { ENDPOINT, MAX_RESPONSE_BYTES, REQUEST_TIMEOUT_MS } from "./constants";
5
+ import { readStream } from "./files";
6
+ import { parseReply, type Reply } from "./wire";
7
+
8
+ export type Fetch = (request: Request) => Promise<Response>;
9
+
10
+ export async function requestAdvice(prepared: Prepared, credential: string, fetcher: Fetch = fetch, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Reply> {
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
13
+ let response: Response | undefined;
14
+ try {
15
+ const request = new Request(ENDPOINT, {
16
+ method: "POST", headers: { Authorization: `Bearer ${credential}`, "Content-Type": "application/json" },
17
+ body: prepared.request, signal: controller.signal, redirect: "error",
18
+ });
19
+ response = await fetcher(request);
20
+ if (!response.ok) throw new OrlyError(`Provider request failed with HTTP ${response.status}; no automatic retry was made.`);
21
+ if (!response.body) throw new OrlyError("Provider reply has no body.");
22
+ const raw = await readStream(response.body, MAX_RESPONSE_BYTES, controller.signal);
23
+ let value: unknown;
24
+ try { value = JSON.parse(raw); } catch { throw new OrlyError("Provider reply is not valid JSON."); }
25
+ return parseReply(value, prepared);
26
+ } catch (error) {
27
+ if (controller.signal.aborted) throw new OrlyError("Provider request timed out and was canceled.");
28
+ if (error instanceof OrlyError) throw error;
29
+ throw new OrlyError("Provider transport failed; no response body or credential was logged.");
30
+ } finally {
31
+ controller.abort();
32
+ clearTimeout(timer);
33
+ if (response?.body && !response.body.locked) await response.body.cancel().catch(() => undefined);
34
+ }
35
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { validateManifest } from "./questions";
4
+ import { TestProject } from "./test_support";
5
+ import { manifestSchema } from "./types";
6
+
7
+ describe("manifest boundaries", () => {
8
+ test("accepts bounded role-labeled evidence", () => {
9
+ using project = new TestProject();
10
+ expect(manifestSchema.parse(project.manifest()).items[0]?.id).toBe("locale");
11
+ });
12
+
13
+ test("rejects unknown questions and instruction fields", () => {
14
+ using project = new TestProject();
15
+ const item = project.item();
16
+ expect(manifestSchema.safeParse({ stage: "verify", items: [{ ...item, question: "approve.everything" }] }).success).toBe(false);
17
+ expect(manifestSchema.safeParse({ ...project.manifest(), run: "echo approve" }).success).toBe(false);
18
+ });
19
+
20
+ test("rejects missing required roles and mismatched stage", () => {
21
+ using project = new TestProject();
22
+ const item = project.item();
23
+ expect(() => validateManifest({ stage: "verify", items: [{ ...item, evidence: item.evidence.slice(0, 1) }] }, "verify")).toThrow("Required evidence roles are missing.");
24
+ expect(() => validateManifest(project.manifest(), "plan")).toThrow("Judgment manifest stage does not match the command.");
25
+ expect(() => validateManifest({ stage: "plan", items: [item] }, "plan")).toThrow("The selected question belongs to another stage.");
26
+ });
27
+
28
+ test("rejects duplicate items and references", () => {
29
+ using project = new TestProject();
30
+ const item = project.item();
31
+ expect(() => validateManifest({ stage: "verify", items: [item, item] }, "verify")).toThrow("Judgment item identifiers must be unique.");
32
+ expect(() => validateManifest({ stage: "verify", items: [{ ...item, evidence: [...item.evidence, ...item.evidence] }] }, "verify")).toThrow("Duplicate evidence references are not allowed.");
33
+ });
34
+
35
+ test("rejects excessive input and terminal control characters", () => {
36
+ using project = new TestProject();
37
+ const item = project.item();
38
+ expect(manifestSchema.safeParse({ stage: "verify", items: Array(13).fill(item) }).success).toBe(false);
39
+ expect(manifestSchema.safeParse({ stage: "verify", items: [{ ...item, requirement: "x".repeat(2049) }] }).success).toBe(false);
40
+ const reference = item.evidence[0];
41
+ expect(manifestSchema.safeParse({ stage: "verify", items: [{ ...item, evidence: [{ ...reference, path: "label\u001b.ts" }] }] }).success).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,66 @@
1
+ import type { Answer } from "./wire";
2
+
3
+ import { z } from "zod";
4
+
5
+ import { MAX_EVIDENCE, MAX_ITEMS, MAX_NAME_LENGTH, MAX_REQUIREMENT_LENGTH, QUESTIONS, ROLES, STAGES } from "./constants";
6
+
7
+ const name = z.string().trim().min(1).max(MAX_NAME_LENGTH);
8
+ const selector = z.discriminatedUnion("kind", [
9
+ z.strictObject({ kind: z.literal("file") }),
10
+ z.strictObject({ kind: z.literal("function"), name }),
11
+ z.strictObject({ kind: z.literal("test"), name }),
12
+ z.strictObject({ kind: z.literal("section"), heading: name }),
13
+ ]);
14
+ export const referenceSchema = z.strictObject({
15
+ role: z.enum(ROLES),
16
+ path: z.string().min(1).max(512).refine((value) => !/[\x00-\x1f\x7f]/.test(value)),
17
+ selector,
18
+ });
19
+ export const itemSchema = z.strictObject({
20
+ id: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,47}$/),
21
+ question: z.enum(QUESTIONS),
22
+ requirement: z.string().trim().min(1).max(MAX_REQUIREMENT_LENGTH),
23
+ evidence: z.array(referenceSchema).min(1).max(MAX_EVIDENCE),
24
+ });
25
+ export const manifestSchema = z.strictObject({ stage: z.enum(STAGES), items: z.array(itemSchema).min(1).max(MAX_ITEMS) });
26
+
27
+ export type Stage = z.infer<typeof manifestSchema>["stage"];
28
+ export type Manifest = z.infer<typeof manifestSchema>;
29
+ export type Item = z.infer<typeof itemSchema>;
30
+ export type Reference = z.infer<typeof referenceSchema>;
31
+ export type Selector = Reference["selector"];
32
+ export type Role = Reference["role"];
33
+ export type QuestionId = Item["question"];
34
+ export type ProviderQuestion =
35
+ | { type: "noul"; instructions: string; criteria: { true: string; false: string } }
36
+ | { type: "choice"; instructions: string; criteria: Record<string, string> };
37
+ export type Definition = {
38
+ stage: Stage;
39
+ requiredRoles: readonly Role[];
40
+ oneOfRoles: readonly Role[];
41
+ question: ProviderQuestion;
42
+ yesAction: string;
43
+ noAction: string;
44
+ yesIsConcern: boolean;
45
+ };
46
+ export type Evidence = { role: Role; path: string; selector: Selector; startLine: number; endLine: number; text: string };
47
+ export type SourceReference = Omit<Evidence, "text"> & { digest: string };
48
+ export type Prepared = {
49
+ item: Item;
50
+ definition: Definition;
51
+ identity: string;
52
+ request: string;
53
+ references: SourceReference[];
54
+ };
55
+ export type Assessment = { decision: string; concern: boolean; uncertain: boolean; strength: number; action: string };
56
+ export type CompletedItem = {
57
+ status: "complete"; id: string; question: QuestionId; mode: "live" | "replay";
58
+ model: string; identity: string; sources: SourceReference[]; assessment: Assessment;
59
+ answer: Answer; requests: number; elapsedMs: number; usage: { input_tokens: number; output_tokens: number };
60
+ };
61
+ export type IncompleteItem = {
62
+ status: "incomplete"; id: string; question: QuestionId; reason: string;
63
+ requests: number; elapsedMs: number; usage: { input_tokens: number; output_tokens: number } | null;
64
+ };
65
+ export type ItemResult = CompletedItem | IncompleteItem;
66
+ export type Report = { stage: Stage; advisory: true; requests: number; results: ItemResult[] };
@@ -0,0 +1,48 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import { CHOICE, REPLY_KEY } from "./constants";
4
+ import { CATALOG } from "./questions";
5
+ import { choiceReply, noulReply, TestProject } from "./test_support";
6
+ import { parseReply } from "./wire";
7
+
8
+ test("validates the requested choice distribution and pinned model", async () => {
9
+ using project = new TestProject();
10
+ const prepared = await project.prepare();
11
+ expect(parseReply(choiceReply(), prepared).answers[REPLY_KEY]).toEqual(choiceReply().answers[REPLY_KEY]);
12
+ });
13
+
14
+ test("rejects invalid schema, model and answer type", async () => {
15
+ using project = new TestProject();
16
+ const prepared = await project.prepare();
17
+ expect(() => parseReply({ ...choiceReply(), model: "jev-latest" }, prepared)).toThrow("Provider reply does not match the pinned model and answer schema.");
18
+ expect(() => parseReply(noulReply(1), prepared)).toThrow("Provider answer has the wrong question type.");
19
+ expect(() => parseReply({ ...choiceReply(), usage: { input_tokens: -1, output_tokens: 0 } }, prepared)).toThrow("Provider reply does not match the pinned model and answer schema.");
20
+ });
21
+
22
+ test("rejects missing answers and extra answers", async () => {
23
+ using project = new TestProject();
24
+ const prepared = await project.prepare();
25
+ const reply = choiceReply();
26
+ expect(() => parseReply({ ...reply, answers: {} }, prepared)).toThrow("Provider reply must answer the requested question exactly once.");
27
+ expect(() => parseReply({ ...reply, answers: { ...reply.answers, extra: reply.answers[REPLY_KEY] } }, prepared)).toThrow("Provider reply must answer the requested question exactly once.");
28
+ });
29
+
30
+ test("rejects omitted options, bad sums and a non-maximum choice", async () => {
31
+ using project = new TestProject();
32
+ const prepared = await project.prepare();
33
+ const reply = choiceReply();
34
+ const answer = reply.answers[REPLY_KEY];
35
+ if (answer?.type !== "choice") throw new Error("Choice fixture is invalid");
36
+ const replace = (probabilities: Record<string, number>, choice: string = CHOICE.weak) => ({ ...reply, answers: { [REPLY_KEY]: { ...answer, probabilities, choice } } });
37
+ expect(() => parseReply(replace({ [CHOICE.weak]: 1 }), prepared)).toThrow("Provider probabilities do not match the requested choices.");
38
+ expect(() => parseReply(replace({ ...answer.probabilities, [CHOICE.exact]: 0.2 }), prepared)).toThrow("Provider probabilities do not sum to one.");
39
+ expect(() => parseReply(replace(answer.probabilities, CHOICE.exact), prepared)).toThrow("Provider choice is not a highest-probability option.");
40
+ });
41
+
42
+ test("rejects non-finite and out-of-range probabilities", async () => {
43
+ using project = new TestProject();
44
+ const prepared = await project.prepare();
45
+ for (const number of [NaN, Infinity, -0.1, 1.1]) {
46
+ expect(() => parseReply(noulReply(number), { ...prepared, definition: CATALOG["plan.prerequisites"] })).toThrow("Provider reply does not match the pinned model and answer schema.");
47
+ }
48
+ });
@@ -0,0 +1,40 @@
1
+ import type { Prepared, ProviderQuestion } from "./types";
2
+
3
+ import { z } from "zod";
4
+
5
+ import { OrlyError } from "../model";
6
+ import { ANSWER_KIND, MODEL, PROBABILITY_TOLERANCE, REPLY_KEY } from "./constants";
7
+
8
+ const probability = z.number().finite().min(0).max(1);
9
+ const answerSchema = z.discriminatedUnion("type", [
10
+ z.strictObject({ type: z.literal(ANSWER_KIND.noul), noul: probability }),
11
+ z.strictObject({ type: z.literal(ANSWER_KIND.choice), choice: z.string(), probabilities: z.record(z.string(), probability), confidence: probability }),
12
+ ]);
13
+ export const usageSchema = z.strictObject({ input_tokens: z.number().int().nonnegative(), output_tokens: z.number().int().nonnegative() });
14
+ export const replySchema = z.object({ model: z.literal(MODEL), answers: z.record(z.string(), answerSchema), usage: usageSchema });
15
+ export type Reply = z.infer<typeof replySchema>;
16
+ export type Answer = z.infer<typeof answerSchema>;
17
+
18
+ export function parseReply(value: unknown, prepared: Prepared): Reply {
19
+ const parsed = replySchema.safeParse(value);
20
+ if (!parsed.success) throw new OrlyError("Provider reply does not match the pinned model and answer schema.");
21
+ const reply = parsed.data;
22
+ const keys = Object.keys(reply.answers);
23
+ const answer = reply.answers[REPLY_KEY];
24
+ if (keys.length !== 1 || !answer) throw new OrlyError("Provider reply must answer the requested question exactly once.");
25
+ validateAnswer(answer, prepared.definition.question);
26
+ return reply;
27
+ }
28
+
29
+ function validateAnswer(answer: Answer, question: ProviderQuestion): void {
30
+ if (answer.type !== question.type) throw new OrlyError("Provider answer has the wrong question type.");
31
+ if (answer.type !== ANSWER_KIND.choice || question.type !== ANSWER_KIND.choice) return;
32
+ const expected = Object.keys(question.criteria).sort();
33
+ const actual = Object.keys(answer.probabilities).sort();
34
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new OrlyError("Provider probabilities do not match the requested choices.");
35
+ const probabilities = Object.values(answer.probabilities);
36
+ const sum = probabilities.reduce((total, value) => total + value, 0);
37
+ if (Math.abs(sum - 1) > PROBABILITY_TOLERANCE) throw new OrlyError("Provider probabilities do not sum to one.");
38
+ const selected = answer.probabilities[answer.choice];
39
+ if (selected === undefined || selected + PROBABILITY_TOLERANCE < Math.max(...probabilities)) throw new OrlyError("Provider choice is not a highest-probability option.");
40
+ }
@@ -32,7 +32,7 @@ describe("runtime loaders", () => {
32
32
  // and passes through the repository's own file on the way to orly's.
33
33
  expect(await Bun.file(join(repo, "CLAUDE.md")).text()).toContain("\n@AGENTS.md\n");
34
34
  expect(JSON.parse(await Bun.file(join(repo, "opencode.json")).text()).instructions)
35
- .toEqual(["AGENTS.md", "AGENTS.orly.md"]);
35
+ .toEqual(["AGENTS.md", ".orly/AGENTS.md"]);
36
36
  });
37
37
 
38
38
  // The line that does the work. Backticked it is decoration, which is what the
@@ -44,8 +44,8 @@ describe("runtime loaders", () => {
44
44
  await installInto(repo);
45
45
 
46
46
  const host = await Bun.file(join(repo, "AGENTS.md")).text();
47
- expect(host).toContain("\n@AGENTS.orly.md\n");
48
- expect(host).not.toContain("`@AGENTS.orly.md`");
47
+ expect(host).toContain("\n@.orly/AGENTS.md\n");
48
+ expect(host).not.toContain("`@.orly/AGENTS.md`");
49
49
  });
50
50
 
51
51
  test("loaders are idempotent — a second install writes nothing", async () => {
@@ -87,22 +87,19 @@ describe("runtime loaders", () => {
87
87
  expect(host).toContain("keep me");
88
88
  expect(host).toContain("trailing prose");
89
89
  expect(host).not.toContain("stale pointer");
90
- expect(host).toContain("\n@AGENTS.orly.md\n");
90
+ expect(host).toContain("\n@.orly/AGENTS.md\n");
91
91
  });
92
92
 
93
- // The migration path for every repository installed before the loaders
94
- // existed, and for every later change to the loader's own wording: the
95
- // banner is the authorship evidence, so orly's own file is orly's to refresh.
96
- test("a CLAUDE.md orly wrote itself is refreshed, not left stale", async () => {
93
+ test("an unrecorded Claude banner cannot authorize deleting existing instructions", async () => {
97
94
  const repo = newRepository();
98
- await Bun.write(join(repo, "CLAUDE.md"), "> **Generated by `orly`.**\n> an older loader\n\n@ELSEWHERE.md\n");
95
+ const ownerContent = "> **Generated by `orly`.**\n> an older loader\n\n@ELSEWHERE.md\n";
96
+ await Bun.write(join(repo, "CLAUDE.md"), ownerContent);
99
97
 
100
98
  const result = await installInto(repo);
101
99
 
102
- expect(result.written).toContain("CLAUDE.md");
100
+ expect(result.written).not.toContain("CLAUDE.md");
103
101
  const loader = await Bun.file(join(repo, "CLAUDE.md")).text();
104
- expect(loader).toContain("\n@AGENTS.md\n");
105
- expect(loader).not.toContain("ELSEWHERE");
102
+ expect(loader).toBe(ownerContent);
106
103
  });
107
104
 
108
105
  // A link orly cannot resolve is still someone's deliberate wiring. Writing
@@ -114,9 +111,9 @@ describe("runtime loaders", () => {
114
111
 
115
112
  const result = await installInto(repo);
116
113
 
117
- expect(result.ok).toBe(true);
114
+ expect(result.ok).toBe(false);
118
115
  expect(result.written).not.toContain("CLAUDE.md");
119
- expect(result.skipped).toContain("CLAUDE.md");
116
+ expect(result.errors.some((error) => error.message.includes("unresolved symbolic link"))).toBe(true);
120
117
  expect(existsSync(join(repo, "nowhere.md"))).toBe(false);
121
118
  expect(lstatSync(join(repo, "CLAUDE.md")).isSymbolicLink()).toBe(true);
122
119
  });
@@ -159,7 +156,7 @@ describe("runtime loaders", () => {
159
156
 
160
157
  const config = JSON.parse(await Bun.file(join(repo, "opencode.json")).text());
161
158
  expect(config.model).toBe("anthropic/claude-fable-5");
162
- expect(config.instructions).toEqual(["AGENTS.md", "AGENTS.orly.md"]);
159
+ expect(config.instructions).toEqual(["AGENTS.md", ".orly/AGENTS.md"]);
163
160
  expect(result.written).toContain("opencode.json");
164
161
  });
165
162
 
@@ -191,6 +188,6 @@ describe("runtime loaders", () => {
191
188
  expect(result.ok).toBe(false);
192
189
  expect(result.errors.some((error) => error.path === "opencode.json")).toBeTrue();
193
190
  expect(await Bun.file(victim).text()).toBe("not orly's to write\n");
194
- expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(false);
191
+ expect(existsSync(join(repo, ".orly/AGENTS.md"))).toBe(false);
195
192
  });
196
193
  });
package/src/loaders.ts CHANGED
@@ -18,7 +18,7 @@ import { assertWritableInside, isObject } from "./model";
18
18
  // once someone else has written one.
19
19
 
20
20
  export const AGENTS_FILENAME = "AGENTS.md";
21
- export const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
21
+ export const ORLY_AGENTS_FILENAME = ".orly/AGENTS.md";
22
22
  export const GENERATED_BANNER = "> **Generated by `orly`.**";
23
23
  export const POINTER_HOST_KIND = "pointer host";
24
24
 
@@ -31,6 +31,7 @@ const POINTER_OPEN = "<!-- orly:begin -->";
31
31
  const POINTER_CLOSE = "<!-- orly:end -->";
32
32
  const NEWLINE = "\n";
33
33
  const JSON_INDENT = 2;
34
+ const PREVIOUS_AGENTS_FILENAME = "AGENTS.orly.md";
34
35
 
35
36
  export type Layout = { orlyFile: string; pointerHost?: string };
36
37
  export type LoaderResult = { written: string[]; skipped: string[] };
@@ -56,7 +57,7 @@ export function loaderTargets(layout: Layout): Array<[string, string]> {
56
57
  // Run after the managed files land, so a repository whose install refused for
57
58
  // any other reason never carries a loader pointing at rules that were not
58
59
  // written.
59
- export async function installLoaders(targetRoot: string, layout: Layout): Promise<LoaderResult> {
60
+ export async function installLoaders(targetRoot: string, layout: Layout, obsolete: readonly string[] = []): Promise<LoaderResult> {
60
61
  const written: string[] = [];
61
62
  const skipped: string[] = [];
62
63
  const record = (path: string, verdict: Verdict) => (verdict === WRITTEN ? written : skipped).push(path);
@@ -68,7 +69,7 @@ export async function installLoaders(targetRoot: string, layout: Layout): Promis
68
69
  const host = layout.pointerHost ?? layout.orlyFile;
69
70
  if (layout.pointerHost) record(layout.pointerHost, await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile));
70
71
  record(CLAUDE_FILENAME, await upsertClaudeLoader(targetRoot, host));
71
- record(OPENCODE_FILENAME, await upsertOpencodeLoader(targetRoot, [...new Set([host, layout.orlyFile])]));
72
+ record(OPENCODE_FILENAME, await upsertOpencodeLoader(targetRoot, [...new Set([host, layout.orlyFile])], obsolete));
72
73
  return { written, skipped };
73
74
  }
74
75
 
@@ -121,7 +122,7 @@ async function upsertPointer(targetRoot: string, host: string, orlyFile: string)
121
122
  // No file, or one orly generated under an older layout where it owned this
122
123
  // name. Neither holds a line the repository wrote, so both become the stub —
123
124
  // which is the migration for anything installed before the split.
124
- if (!existsSync(path) || (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) {
125
+ if (!existsSync(path)) {
125
126
  await Bun.write(path, `${stubHost(block)}\n`);
126
127
  return WRITTEN;
127
128
  }
@@ -161,14 +162,28 @@ async function upsertClaudeLoader(targetRoot: string, host: string): Promise<Ver
161
162
  }
162
163
  const current = await Bun.file(path).text();
163
164
  if (current === content) return CURRENT;
164
- // An older loader of orly's own making is orly's to refresh; anything else
165
- // is the repository's file and is reported, not edited.
166
- if (!current.startsWith(GENERATED_BANNER)) return THEIRS;
165
+ const start = current.indexOf(POINTER_OPEN);
166
+ const end = current.indexOf(POINTER_CLOSE);
167
+ if (start >= 0 && end > start) {
168
+ const updated = `${current.slice(0, start)}${claudeBlock(host)}${current.slice(end + POINTER_CLOSE.length)}`;
169
+ if (updated === current) return CURRENT;
170
+ await Bun.write(path, updated);
171
+ return WRITTEN;
172
+ }
173
+ if (current !== previousClaudeLoader(host) && current !== previousClaudeLoader(PREVIOUS_AGENTS_FILENAME)) return THEIRS;
167
174
  await Bun.write(path, content);
168
175
  return WRITTEN;
169
176
  }
170
177
 
171
178
  function claudeLoader(host: string): string {
179
+ return [GENERATED_BANNER, claudeBlock(host), ""].join(NEWLINE);
180
+ }
181
+
182
+ function claudeBlock(host: string): string {
183
+ return [POINTER_OPEN, `@${host}`, POINTER_CLOSE].join(NEWLINE);
184
+ }
185
+
186
+ function previousClaudeLoader(host: string): string {
172
187
  return [
173
188
  GENERATED_BANNER,
174
189
  "> Claude Code loads this file at session start and no other, so this is where",
@@ -214,7 +229,7 @@ function linkVerdict(targetRoot: string, path: string, host: string): Verdict |
214
229
  // An existing config is edited by adding what is missing and nothing else. A
215
230
  // config orly cannot parse is left exactly as it is: rewriting a file whose
216
231
  // shape we do not understand is how an install eats someone's settings.
217
- async function upsertOpencodeLoader(targetRoot: string, entries: string[]): Promise<Verdict> {
232
+ async function upsertOpencodeLoader(targetRoot: string, entries: string[], obsolete: readonly string[]): Promise<Verdict> {
218
233
  assertWritableInside(targetRoot, OPENCODE_FILENAME, LOADER_KIND);
219
234
  const path = join(targetRoot, OPENCODE_FILENAME);
220
235
  if (!existsSync(path)) {
@@ -228,9 +243,12 @@ async function upsertOpencodeLoader(targetRoot: string, entries: string[]): Prom
228
243
  return THEIRS;
229
244
  }
230
245
  if (!isObject(config)) return THEIRS;
231
- const listed = Array.isArray(config[INSTRUCTIONS_KEY]) ? config[INSTRUCTIONS_KEY].map(String) : [];
246
+ const instructions = config[INSTRUCTIONS_KEY];
247
+ if (instructions !== undefined && (!Array.isArray(instructions) || instructions.some((entry) => typeof entry !== "string"))) return THEIRS;
248
+ const original: string[] = Array.isArray(instructions) ? instructions : [];
249
+ const listed = original.filter((entry) => !obsolete.includes(entry));
232
250
  const missing = entries.filter((entry) => !listed.includes(entry));
233
- if (missing.length === 0) return CURRENT;
251
+ if (missing.length === 0 && listed.length === original.length) return CURRENT;
234
252
  await Bun.write(path, serialise({ ...config, [INSTRUCTIONS_KEY]: [...listed, ...missing] }));
235
253
  return WRITTEN;
236
254
  }
package/src/model.test.ts CHANGED
@@ -1,9 +1,26 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { resolve } from "node:path";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, symlinkSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
3
4
 
4
- import { readJsonObject, RulesModel } from "./model";
5
+ import { assertWritableInside, readJsonObject, RulesModel } from "./model";
6
+ import { cleanupTemporaryDirectories, temporaryDirectory } from "./gates_test_support";
5
7
 
6
8
  const ROOT = resolve(import.meta.dir, "..");
9
+ const FIRST_LINK = "first-link";
10
+ const SECOND_LINK = "second-link";
11
+ const MISSING = "missing-destination";
12
+
13
+ afterEach(cleanupTemporaryDirectories);
14
+
15
+ test("a dangling multi-link destination refuses before creating outside content", () => {
16
+ const root = temporaryDirectory();
17
+ const outside = temporaryDirectory();
18
+ symlinkSync(join(outside, MISSING), join(root, SECOND_LINK));
19
+ symlinkSync(SECOND_LINK, join(root, FIRST_LINK));
20
+ expect(() => assertWritableInside(root, FIRST_LINK, "probe")).toThrow("unresolved symbolic link");
21
+ expect(() => assertWritableInside(root, `${FIRST_LINK}/child`, "probe")).toThrow("unresolved symbolic link");
22
+ expect(existsSync(join(outside, MISSING))).toBe(false);
23
+ });
7
24
 
8
25
  describe("RulesModel", () => {
9
26
  test("validates the current registry", async () => {
package/src/model.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, lstatSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
 
4
4
  import { validateActiveRule, validateRelativePath } from "./validation";
@@ -7,8 +7,8 @@ export type JsonObject = Record<string, unknown>;
7
7
 
8
8
  const HASH_ALGORITHM = "sha256";
9
9
  const HASH_ENCODING = "hex";
10
- const MODE_EXECUTABLE = "0755";
11
- const MODE_REGULAR = "0644";
10
+ export const MODE_EXECUTABLE = "0755";
11
+ export const MODE_REGULAR = "0644";
12
12
  const EXECUTABLE_BITS = 0o755;
13
13
  const OCTAL = 8;
14
14
 
@@ -41,6 +41,7 @@ export function isBelow(path: string, root: string): boolean {
41
41
  export function nearestExistingAncestor(path: string): string {
42
42
  let current = path;
43
43
  while (!existsSync(current)) {
44
+ if (isSymbolicLink(current)) safeRealpath(current);
44
45
  const parent = dirname(current);
45
46
  if (parent === current) return current;
46
47
  current = parent;
@@ -81,23 +82,12 @@ function isSymbolicLink(path: string): boolean {
81
82
  }
82
83
  }
83
84
 
84
- // A dangling link resolves nowhere; judge it by where it points, not where it
85
- // lands, so a link to a not-yet-created file outside the repository still fails.
86
- //
87
- // The directory holding it is resolved too, and has to be: the caller compares
88
- // this against a realpath'd root, and a lexical answer put a link pointing
89
- // squarely INSIDE the repository on the wrong side of that comparison wherever
90
- // the repository itself sits behind a symlink — which is every macOS temporary
91
- // directory, and plenty of real checkouts. The refusal even named the file it
92
- // was about to protect, one path component at a time.
93
- //
94
- // Resolving the parent cannot fail here: this runs only where the destination
95
- // itself lstat'd as a link, which a missing parent makes impossible.
96
- function safeRealpath(path: string): string | undefined {
85
+ // Unresolved links cannot prove containment, including chains with missing parents.
86
+ function safeRealpath(path: string): string {
97
87
  try {
98
88
  return realpathSync(path);
99
89
  } catch {
100
- return resolve(realpathSync(dirname(path)), readlinkSync(path));
90
+ throw new OrlyError(`refusing unresolved symbolic link: ${path}`);
101
91
  }
102
92
  }
103
93
 
@@ -252,4 +242,3 @@ export function normalizedMode(path: string): number {
252
242
  export function setNormalizedMode(path: string, source: string): void {
253
243
  chmodSync(path, normalizedMode(source));
254
244
  }
255
-
@@ -8,8 +8,48 @@ import { PRODUCT_ONLY_TARGETS, productLeakErrors, shippedFiles } from "./pack_hy
8
8
  const registryFor = (pack: string, source: string) => ({
9
9
  packs: { [pack]: { documents: [{ source, target: source }] } },
10
10
  });
11
+ const GUIDE_PATH = "docs/GUIDE.md";
12
+ const PRODUCT_COMMAND = "Run `make test-integration` before you ship.";
11
13
 
12
14
  describe("productLeakErrors", () => {
15
+ test("checks string core documents and retains source lines after product blocks", async () => {
16
+ const output = mkdtempSync(join(tmpdir(), "orly-core-hygiene-"));
17
+ try {
18
+ mkdirSync(join(output, "docs"));
19
+ await Bun.write(join(output, GUIDE_PATH), [
20
+ "<!-- oracle-packs:start product.agentsfleet -->",
21
+ PRODUCT_COMMAND,
22
+ "<!-- oracle-packs:end -->",
23
+ PRODUCT_COMMAND,
24
+ ].join("\n"));
25
+ const errors = await productLeakErrors(output, {
26
+ core_documents: [GUIDE_PATH],
27
+ packs: { "product.agentsfleet": {} },
28
+ });
29
+ expect(errors).toHaveLength(1);
30
+ expect(errors[0]).toContain(`${GUIDE_PATH}:4`);
31
+ } finally {
32
+ rmSync(output, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test("allows a product-only command when the installer excludes its block", async () => {
37
+ const output = mkdtempSync(join(tmpdir(), "orly-profile-hygiene-"));
38
+ try {
39
+ mkdirSync(join(output, "docs"));
40
+ await Bun.write(join(output, GUIDE_PATH), [
41
+ "<!-- oracle-packs:start product.agentsfleet -->",
42
+ PRODUCT_COMMAND,
43
+ "<!-- oracle-packs:end -->",
44
+ "Use the repository's declared integration command.",
45
+ ].join("\n"));
46
+ const registry = registryFor("domain.http", GUIDE_PATH);
47
+ Object.assign(registry.packs, { "product.agentsfleet": {} });
48
+ expect(await productLeakErrors(output, registry)).toEqual([]);
49
+ } finally {
50
+ rmSync(output, { recursive: true, force: true });
51
+ }
52
+ });
13
53
  test("flags a product-only target named by a generic pack", async () => {
14
54
  const output = mkdtempSync(join(tmpdir(), "orly-hygiene-"));
15
55
  try {
@@ -81,12 +121,13 @@ describe("shippedFiles", () => {
81
121
  "domain.http": { documents: [{ source: "docs/A.md" }] },
82
122
  "product.agentsfleet": { documents: [{ source: "docs/B.md" }] },
83
123
  },
84
- core_documents: [{ source: "docs/C.md" }],
124
+ core_documents: [{ source: "docs/C.md" }, "docs/D.md"],
85
125
  });
86
126
 
87
127
  expect(files).toContainEqual({ source: "docs/A.md", pack: "domain.http" });
88
128
  expect(files).toContainEqual({ source: "docs/B.md", pack: "product.agentsfleet" });
89
129
  expect(files).toContainEqual({ source: "docs/C.md", pack: "core" });
130
+ expect(files).toContainEqual({ source: "docs/D.md", pack: "core" });
90
131
  });
91
132
  });
92
133