oris-skills 2.0.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 (81) hide show
  1. package/.cursor-plugin/plugin.json +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/agents/oris-loop-debriefer.md +29 -0
  5. package/agents/oris-loop-doctor.md +32 -0
  6. package/agents/oris-loop-executor.md +32 -0
  7. package/agents/oris-loop-verifier.md +31 -0
  8. package/docs/architecture.md +26 -0
  9. package/docs/distribution.md +38 -0
  10. package/docs/maintainer-guide.md +32 -0
  11. package/docs/user-guide.md +42 -0
  12. package/package.json +49 -0
  13. package/references/clean-code-checklist.md +107 -0
  14. package/references/conventions.md +39 -0
  15. package/references/doc-policy.md +24 -0
  16. package/references/loop-adapter.schema.json +126 -0
  17. package/references/loop-contract.md +119 -0
  18. package/references/loop.schema.json +143 -0
  19. package/references/questions.md +38 -0
  20. package/references/repo-map.md +53 -0
  21. package/references/repo-map.schema.json +198 -0
  22. package/references/settings.md +35 -0
  23. package/references/settings.schema.json +75 -0
  24. package/scripts/flow/oris-flow-clean-runtime.mjs +182 -0
  25. package/scripts/flow/oris-flow-layout.mjs +53 -0
  26. package/scripts/flow/oris-flow-scan.mjs +350 -0
  27. package/scripts/flow/oris-flow-version-control.mjs +42 -0
  28. package/scripts/flow/oris-gitignore.mjs +79 -0
  29. package/scripts/install/generate-agent-adapters.mjs +74 -0
  30. package/scripts/install/install-user-skills.mjs +271 -0
  31. package/scripts/install/uninstall-user-skills.mjs +163 -0
  32. package/scripts/loop/oris-loop-bootstrap.mjs +383 -0
  33. package/scripts/loop/oris-loop-bundle.mjs +22 -0
  34. package/scripts/loop/oris-loop-chat.mjs +408 -0
  35. package/scripts/loop/oris-loop-demo.mjs +180 -0
  36. package/scripts/loop/oris-loop-document.mjs +179 -0
  37. package/scripts/loop/oris-loop-dry-run.mjs +110 -0
  38. package/scripts/loop/oris-loop-fixtures.mjs +148 -0
  39. package/scripts/loop/oris-loop-list.mjs +81 -0
  40. package/scripts/loop/oris-loop-paths.mjs +232 -0
  41. package/scripts/loop/oris-loop-run.mjs +241 -0
  42. package/scripts/loop/oris-loop-stop.mjs +319 -0
  43. package/scripts/loop/oris-loop-templates.mjs +77 -0
  44. package/scripts/loop/oris-loop-verify.mjs +206 -0
  45. package/scripts/oris-skills.mjs +116 -0
  46. package/scripts/package-oris-skills.mjs +53 -0
  47. package/scripts/tests/run-all-tests.mjs +38 -0
  48. package/scripts/tests/test-agent-adapters.mjs +19 -0
  49. package/scripts/tests/test-oris-1-0-cleanliness.mjs +58 -0
  50. package/scripts/tests/test-oris-flow-clean-runtime.mjs +75 -0
  51. package/scripts/tests/test-oris-flow-scan.mjs +49 -0
  52. package/scripts/tests/test-oris-gitignore.mjs +35 -0
  53. package/scripts/tests/test-oris-loop-bootstrap.mjs +94 -0
  54. package/scripts/tests/test-oris-loop-document.mjs +112 -0
  55. package/scripts/tests/test-oris-loop-list.mjs +74 -0
  56. package/scripts/tests/test-oris-loop-run.mjs +45 -0
  57. package/scripts/tests/test-oris-loop-smoke.mjs +130 -0
  58. package/scripts/tests/test-oris-loop-stop.mjs +371 -0
  59. package/scripts/tests/test-routing-lifecycle.mjs +181 -0
  60. package/scripts/tests/test-schemas.mjs +55 -0
  61. package/skills/oris-flow/SKILL.md +55 -0
  62. package/skills/oris-flow/agents/openai.yaml +4 -0
  63. package/skills/oris-flow-criteria/SKILL.md +49 -0
  64. package/skills/oris-flow-discover/SKILL.md +58 -0
  65. package/skills/oris-flow-docs/SKILL.md +31 -0
  66. package/skills/oris-flow-fix/SKILL.md +42 -0
  67. package/skills/oris-flow-implement/SKILL.md +43 -0
  68. package/skills/oris-flow-plan/SKILL.md +51 -0
  69. package/skills/oris-flow-setup/SKILL.md +49 -0
  70. package/skills/oris-help/SKILL.md +30 -0
  71. package/skills/oris-help/agents/openai.yaml +4 -0
  72. package/skills/oris-loop/SKILL.md +66 -0
  73. package/skills/oris-loop/agents/openai.yaml +4 -0
  74. package/skills/oris-loop/references/craft.md +48 -0
  75. package/skills/oris-loop/references/improve.md +23 -0
  76. package/skills/oris-loop/references/run.md +30 -0
  77. package/skills/oris-loop/templates/debriefer.md +18 -0
  78. package/skills/oris-loop/templates/doctor.md +20 -0
  79. package/skills/oris-loop/templates/executor.md +19 -0
  80. package/skills/oris-loop/templates/orchestrator.md +33 -0
  81. package/skills/oris-loop/templates/verifier.md +21 -0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import {
9
+ defaultLoopDocumentPaths,
10
+ discoverExistingLoops,
11
+ resolveLoopDocumentPaths,
12
+ } from "../loop/oris-loop-paths.mjs";
13
+ import { loopDocumentSource } from "../loop/oris-loop-fixtures.mjs";
14
+ import { ORIS_FLOW_LOOPS_ROOT } from "../flow/oris-flow-layout.mjs";
15
+
16
+ function makeTempRepo() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-list-"));
18
+ }
19
+
20
+ test("discoverExistingLoops finds approved loop documents", () => {
21
+ const root = makeTempRepo();
22
+ try {
23
+ const loopFile = path.join(root, ORIS_FLOW_LOOPS_ROOT, "feature-a", "loop.md");
24
+ const contextFile = path.join(root, ORIS_FLOW_LOOPS_ROOT, "feature-a", "context.md");
25
+ fs.mkdirSync(path.dirname(loopFile), { recursive: true });
26
+ fs.writeFileSync(loopFile, loopDocumentSource({ program: "feature-a", goal: "Verify feature A." }), "utf8");
27
+ fs.writeFileSync(contextFile, "# Oris Loop Context\n\nRun: inactive\nState: ready\nCurrent program: feature-a\n\n## Objective\n\nVerify feature A\n", "utf8");
28
+
29
+ const loops = discoverExistingLoops(root);
30
+ assert.equal(loops.length, 1);
31
+ assert.equal(loops[0].slug, "feature-a");
32
+ assert.equal(loops[0].program, "feature-a");
33
+ assert.match(loops[0].objective, /Verify feature A/);
34
+ } finally {
35
+ fs.rmSync(root, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test("discoverExistingLoops ignores incomplete folders", () => {
40
+ const root = makeTempRepo();
41
+ try {
42
+ const partial = path.join(root, ORIS_FLOW_LOOPS_ROOT, "partial", "loop.md");
43
+ fs.mkdirSync(path.dirname(partial), { recursive: true });
44
+ fs.writeFileSync(partial, "not a loop\n", "utf8");
45
+ assert.deepEqual(discoverExistingLoops(root), []);
46
+ } finally {
47
+ fs.rmSync(root, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("defaultLoopDocumentPaths includes the prompts directory", () => {
52
+ const root = makeTempRepo();
53
+ try {
54
+ const docs = defaultLoopDocumentPaths(root, "docs/tasks/my-feature");
55
+ assert.match(docs.loopRelative, /^\.oris-flow\/loops\/my-feature\/loop\.md$/);
56
+ assert.match(docs.contextRelative, /^\.oris-flow\/loops\/my-feature\/context\.md$/);
57
+ assert.match(docs.promptsRelative, /^\.oris-flow\/loops\/my-feature\/prompts$/);
58
+ } finally {
59
+ fs.rmSync(root, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test("resolveLoopDocumentPaths resolves loop documents", () => {
64
+ const root = makeTempRepo();
65
+ try {
66
+ const taskPath = "docs/tasks/preferred";
67
+ const resolved = resolveLoopDocumentPaths(root, { taskPath });
68
+ assert.match(resolved.loopRelative, /^\.oris-flow\/loops\/preferred\/loop\.md$/);
69
+ assert.match(resolved.contextRelative, /^\.oris-flow\/loops\/preferred\/context\.md$/);
70
+ assert.match(resolved.promptsRelative, /^\.oris-flow\/loops\/preferred\/prompts$/);
71
+ } finally {
72
+ fs.rmSync(root, { recursive: true, force: true });
73
+ }
74
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildRoleInvocation, doctorDecisionToState, extractJson } from "../loop/oris-loop-run.mjs";
4
+ import { readTemplate, stripTemplateHeading, substitutePlaceholders } from "../loop/oris-loop-templates.mjs";
5
+
6
+ test("buildRoleInvocation maps roles to codex and claude CLI calls with per-role models", () => {
7
+ const codex = buildRoleInvocation("codex", { prompt: "do it", model: "model-x", root: "/repo" });
8
+ assert.equal(codex.command, "codex");
9
+ assert.deepEqual(codex.args, ["exec", "--sandbox", "workspace-write", "--cd", "/repo", "-m", "model-x", "do it"]);
10
+
11
+ const claude = buildRoleInvocation("claude", { prompt: "do it", model: "model-y", root: "/repo" });
12
+ assert.equal(claude.command, "claude");
13
+ assert.deepEqual(claude.args, ["-p", "do it", "--permission-mode", "acceptEdits", "--model", "model-y"]);
14
+
15
+ const inherit = buildRoleInvocation("claude", { prompt: "do it", model: "inherit", root: "/repo" });
16
+ assert.ok(!inherit.args.includes("--model"), "inherit must not pass an explicit model");
17
+ });
18
+
19
+ test("extractJson pulls the last valid JSON object from noisy output", () => {
20
+ const output = 'thinking...\n{"broken": \nand then {"role":"doctor","decision":"complete"} trailing';
21
+ assert.deepEqual(extractJson(output), { role: "doctor", decision: "complete" });
22
+ assert.equal(extractJson("no json here"), null);
23
+ });
24
+
25
+ test("doctorDecisionToState maps decisions to loop states", () => {
26
+ assert.equal(doctorDecisionToState("complete"), "complete");
27
+ assert.equal(doctorDecisionToState("continue"), "continue");
28
+ assert.equal(doctorDecisionToState("retry"), "continue");
29
+ assert.equal(doctorDecisionToState("advance"), "advance");
30
+ assert.equal(doctorDecisionToState("stop"), "blocked");
31
+ assert.equal(doctorDecisionToState(undefined), "blocked");
32
+ });
33
+
34
+ test("orchestrator template resolves from the skill templates and substitutes placeholders", () => {
35
+ const template = readTemplate("orchestrator", import.meta.url);
36
+ assert.match(template, /ORIS LOOP RUN/);
37
+ const filled = substitutePlaceholders(template, { iteration: 3, maxIterations: 9, loop: "demo" });
38
+ assert.match(filled, /pass 3\/9/);
39
+ assert.match(filled, /\{\{phase\}\}/, "unknown placeholders stay intact");
40
+ });
41
+
42
+ test("stripTemplateHeading removes the human comment line only", () => {
43
+ assert.equal(stripTemplateHeading("# Comment line\n\nBody {{x}}\n"), "Body {{x}}");
44
+ assert.equal(stripTemplateHeading("Body only\n"), "Body only");
45
+ });
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { initSmokeRepository } from "../loop/oris-loop-fixtures.mjs";
9
+ import { ORIS_FLOW_ADAPTER, ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
10
+
11
+ const scriptsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "loop");
12
+
13
+ function runScript(scriptName, args = []) {
14
+ return spawnSync(process.execPath, [path.join(scriptsRoot, scriptName), ...args], {
15
+ cwd: scriptsRoot,
16
+ encoding: "utf8",
17
+ env: {
18
+ ...process.env,
19
+ ORIS_SKILLS_SCRIPTS_ROOT: scriptsRoot,
20
+ },
21
+ });
22
+ }
23
+
24
+ test("same-chat verify passes on temporary repository", () => {
25
+ const result = runScript("oris-loop-verify.mjs", ["--temp"]);
26
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
27
+ assert.match(result.stdout, /Same-chat verification passed\./);
28
+ });
29
+
30
+ test("phase advance does not consume repair-cycle limit", () => {
31
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-advance-"));
32
+ try {
33
+ const task = initSmokeRepository(root, {
34
+ taskSlug: "advance-smoke",
35
+ program: "advance-smoke",
36
+ phases: ["test", "fix", "clean"],
37
+ });
38
+ const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
39
+ const common = ["--repository-root", root, "--task", task.taskPath, "--max-repair-cycles", "2"];
40
+ const start = spawnSync(process.execPath, [chat, "--action", "start", ...common], { encoding: "utf8" });
41
+ assert.equal(start.status, 0, `${start.stdout}\n${start.stderr}`);
42
+
43
+ for (let index = 0; index < 2; index += 1) {
44
+ const advance = spawnSync(process.execPath, [
45
+ chat,
46
+ "--action", "set-state",
47
+ "--state", "advance",
48
+ "--repository-root", root,
49
+ ], { encoding: "utf8" });
50
+ assert.equal(advance.status, 0, `${advance.stdout}\n${advance.stderr}`);
51
+ }
52
+
53
+ const state = JSON.parse(fs.readFileSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json"), "utf8"));
54
+ assert.equal(state.active, true);
55
+ assert.equal(state.state, "advance");
56
+ assert.equal(state.repairCycles, 0);
57
+ assert.equal(state.phaseName, "clean");
58
+ } finally {
59
+ fs.rmSync(root, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test("same-chat start accepts an existing loop slug", () => {
64
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-slug-"));
65
+ try {
66
+ initSmokeRepository(root, {
67
+ taskSlug: "feature-121313-therapy-quality-hardening",
68
+ program: "therapy-quality-hardening",
69
+ });
70
+ const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
71
+ const start = spawnSync(process.execPath, [
72
+ chat,
73
+ "--action", "start",
74
+ "--loop", "feature-121313-therapy-quality-hardening",
75
+ "--repository-root", root,
76
+ ], { encoding: "utf8" });
77
+ assert.equal(start.status, 0, `${start.stdout}\n${start.stderr}`);
78
+
79
+ const state = JSON.parse(fs.readFileSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json"), "utf8"));
80
+ assert.equal(state.active, true);
81
+ assert.equal(state.taskPath, "feature-121313-therapy-quality-hardening");
82
+ assert.equal(state.loopPath, ".oris-flow/loops/feature-121313-therapy-quality-hardening/loop.md");
83
+ } finally {
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("missing required profile leaves same-chat loop unarmed", () => {
89
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-profile-"));
90
+ try {
91
+ initSmokeRepository(root, {
92
+ taskSlug: "profile-smoke",
93
+ program: "profile-smoke",
94
+ });
95
+ const adapterPath = path.join(root, ORIS_FLOW_ADAPTER);
96
+ const adapter = JSON.parse(fs.readFileSync(adapterPath, "utf8"));
97
+ adapter.profile = {
98
+ required: true,
99
+ defaultRole: "testing",
100
+ mappings: [
101
+ { env: "BASE_URL", from: "values.baseUrl", required: true, secret: false },
102
+ ],
103
+ };
104
+ fs.writeFileSync(adapterPath, `${JSON.stringify(adapter, null, 2)}\n`, "utf8");
105
+ const settingsPath = path.join(root, "settings.json");
106
+ fs.writeFileSync(settingsPath, `${JSON.stringify({
107
+ version: 1,
108
+ orisFlow: { uiLanguage: "en", artifactLanguage: "en" },
109
+ defaultProfiles: { testing: "default" },
110
+ profiles: {
111
+ default: { environment: "local", values: {}, secrets: {}, flags: {} },
112
+ },
113
+ }, null, 2)}\n`, "utf8");
114
+
115
+ const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
116
+ const start = spawnSync(process.execPath, [
117
+ chat,
118
+ "--action", "start",
119
+ "--loop", "profile-smoke",
120
+ "--repository-root", root,
121
+ "--settings-path", settingsPath,
122
+ ], { encoding: "utf8" });
123
+ assert.notEqual(start.status, 0);
124
+ assert.match(start.stderr, /missing required field 'values\.baseUrl'/);
125
+ assert.match(start.stderr, /Oris chat loop was not armed; the stop hook will not resume this chat/);
126
+ assert.equal(fs.existsSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json")), false);
127
+ } finally {
128
+ fs.rmSync(root, { recursive: true, force: true });
129
+ }
130
+ });
@@ -0,0 +1,371 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
9
+
10
+ const scriptsRoot = path.dirname(fileURLToPath(import.meta.url));
11
+ const hook = path.join(scriptsRoot, "..", "loop", "oris-loop-stop.mjs");
12
+
13
+ function runHook(root, input, state, extraArgs = []) {
14
+ const statePath = path.join(root, "chat-active.json");
15
+ if (state !== undefined) {
16
+ fs.writeFileSync(statePath, `${JSON.stringify(state)}\n`, "utf8");
17
+ }
18
+ const result = spawnSync(process.execPath, [hook, ...extraArgs], {
19
+ input: JSON.stringify(input),
20
+ encoding: "utf8",
21
+ env: { ...process.env, ORIS_LOOP_CHAT_STATE: statePath },
22
+ });
23
+ assert.equal(result.status, 0, result.stderr);
24
+ return {
25
+ output: JSON.parse(result.stdout.trim()),
26
+ statePath,
27
+ state: fs.existsSync(statePath)
28
+ ? JSON.parse(fs.readFileSync(statePath, "utf8"))
29
+ : undefined,
30
+ };
31
+ }
32
+
33
+ function activeState(overrides = {}) {
34
+ return {
35
+ schemaVersion: 1,
36
+ active: true,
37
+ state: "continue",
38
+ program: "custom",
39
+ phases: ["test", "fix", "clean"],
40
+ phaseIndex: 0,
41
+ phaseName: "test",
42
+ phaseIteration: 0,
43
+ taskPath: "sample",
44
+ loopPath: ".oris-flow/loops/sample/loop.md",
45
+ contextPath: ".oris-flow/loops/sample/context.md",
46
+ promptsPath: ".oris-flow/loops/sample/prompts",
47
+ improveMode: "auto",
48
+ models: {
49
+ default: "inherit",
50
+ executor: "model-e",
51
+ verifier: "model-v",
52
+ doctor: "model-d",
53
+ debriefer: "inherit",
54
+ },
55
+ conversationId: null,
56
+ iteration: 0,
57
+ maxIterations: 5,
58
+ repairCycles: 0,
59
+ maxRepairCycles: 2,
60
+ maxMinutes: 30,
61
+ startedAt: new Date().toISOString(),
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ test("is a no-op without active state", () => {
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
68
+ try {
69
+ const result = runHook(root, {
70
+ conversation_id: "c1",
71
+ status: "completed",
72
+ loop_count: 0,
73
+ });
74
+ assert.deepEqual(result.output, {});
75
+ } finally {
76
+ fs.rmSync(root, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test("cursor: claims the conversation and injects the orchestrator prompt", () => {
81
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
82
+ try {
83
+ const result = runHook(
84
+ root,
85
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
86
+ activeState(),
87
+ );
88
+ const message = result.output.followup_message;
89
+ assert.match(message, /ORIS LOOP RUN — pass 1\/5/);
90
+ assert.match(message, /Loop: custom \| Phase: test \(1\/3\)/);
91
+ assert.match(message, /Definition: \.oris-flow\/loops\/sample\/loop\.md/);
92
+ assert.match(message, /Prompts: \.oris-flow\/loops\/sample\/prompts/);
93
+ assert.match(message, /executor=model-e verifier=model-v doctor=model-d debriefer=inherit/);
94
+ assert.match(message, /Improve mode: auto/);
95
+ assert.match(message, /ROLE: you are the orchestrator only/);
96
+ assert.match(message, /SPAWN executor subagent/);
97
+ assert.match(message, /SPAWN verifier subagent/);
98
+ assert.match(message, /SPAWN doctor subagent/);
99
+ assert.match(message, /oris-skills loop chat --action set-state --state/);
100
+ assert.match(message, /ORIS_LOOP_STATE: continue \| advance \| complete \| blocked \| ask_user/);
101
+ assert.doesNotMatch(message, /\{\{iteration\}\}/, "runtime placeholders must be substituted");
102
+ assert.equal(result.state.conversationId, "c1");
103
+ assert.equal(result.state.iteration, 1);
104
+ assert.equal(result.state.state, "running");
105
+ } finally {
106
+ fs.rmSync(root, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ test("claude: emits decision block with the same orchestrator prompt", () => {
111
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
112
+ try {
113
+ const result = runHook(
114
+ root,
115
+ { session_id: "s1", hook_event_name: "Stop", stop_hook_active: false, cwd: root },
116
+ activeState(),
117
+ ["--platform", "claude"],
118
+ );
119
+ assert.equal(result.output.decision, "block");
120
+ assert.match(result.output.reason, /ORIS LOOP RUN — pass 1\/5/);
121
+ assert.match(result.output.reason, /ROLE: you are the orchestrator only/);
122
+ assert.equal(result.state.conversationId, "s1");
123
+ assert.equal(result.state.iteration, 1);
124
+ } finally {
125
+ fs.rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("claude: platform is auto-detected from Stop payload", () => {
130
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
131
+ try {
132
+ const result = runHook(
133
+ root,
134
+ { session_id: "s2", hook_event_name: "Stop", stop_hook_active: true, cwd: root },
135
+ activeState(),
136
+ );
137
+ assert.equal(result.output.decision, "block");
138
+ assert.ok(result.output.reason.length > 0);
139
+ } finally {
140
+ fs.rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test("per-loop prompts/orchestrator.md overrides the default template", () => {
145
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
146
+ try {
147
+ const promptsDir = path.join(root, ".oris-flow", "loops", "sample", "prompts");
148
+ fs.mkdirSync(promptsDir, { recursive: true });
149
+ fs.writeFileSync(path.join(promptsDir, "orchestrator.md"), "# Custom orchestrator\nCUSTOM PASS {{iteration}} for {{loop}}\n", "utf8");
150
+ const result = runHook(
151
+ root,
152
+ { conversation_id: "c1", status: "completed", loop_count: 0, workspace_roots: [root] },
153
+ activeState({ repositoryRoot: root.replaceAll("\\", "/") }),
154
+ );
155
+ assert.equal(result.output.followup_message, "CUSTOM PASS 1 for custom");
156
+ } finally {
157
+ fs.rmSync(root, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("does not continue another conversation or failed stop", () => {
162
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
163
+ try {
164
+ const mismatch = runHook(
165
+ root,
166
+ { conversation_id: "other", status: "completed", loop_count: 0 },
167
+ activeState({ conversationId: "bound" }),
168
+ );
169
+ assert.deepEqual(mismatch.output, {});
170
+ const failed = runHook(
171
+ root,
172
+ { conversation_id: "bound", status: "error", loop_count: 0 },
173
+ activeState({ conversationId: "bound" }),
174
+ );
175
+ assert.deepEqual(failed.output, {});
176
+ } finally {
177
+ fs.rmSync(root, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("stops on terminal state and exhausted limits", () => {
182
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
183
+ try {
184
+ for (const state of ["complete", "blocked", "cancelled"]) {
185
+ const terminal = runHook(
186
+ root,
187
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
188
+ activeState({ state, conversationId: "c1" }),
189
+ );
190
+ assert.deepEqual(terminal.output, {});
191
+ }
192
+
193
+ const limited = runHook(
194
+ root,
195
+ { conversation_id: "c1", status: "completed", loop_count: 2 },
196
+ activeState({
197
+ conversationId: "c1",
198
+ iteration: 2,
199
+ maxIterations: 2,
200
+ }),
201
+ );
202
+ assert.deepEqual(limited.output, {});
203
+ assert.equal(limited.state.active, false);
204
+ assert.equal(limited.state.state, "blocked");
205
+
206
+ const expired = runHook(
207
+ root,
208
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
209
+ activeState({
210
+ conversationId: "c1",
211
+ maxMinutes: 1,
212
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
213
+ }),
214
+ );
215
+ assert.deepEqual(expired.output, {});
216
+ assert.equal(expired.state.state, "blocked");
217
+ } finally {
218
+ fs.rmSync(root, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test("is a no-op for corrupted runtime state", () => {
223
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
224
+ const statePath = path.join(root, "chat-active.json");
225
+ try {
226
+ fs.writeFileSync(statePath, "{broken", "utf8");
227
+ const result = spawnSync(process.execPath, [hook], {
228
+ input: JSON.stringify({ conversation_id: "c1", status: "completed", loop_count: 0 }),
229
+ encoding: "utf8",
230
+ env: { ...process.env, ORIS_LOOP_CHAT_STATE: statePath },
231
+ });
232
+ assert.equal(result.status, 0, result.stderr);
233
+ assert.deepEqual(JSON.parse(result.stdout.trim()), {});
234
+ } finally {
235
+ fs.rmSync(root, { recursive: true, force: true });
236
+ }
237
+ });
238
+
239
+ test("does not continue unsupported runtime schema", () => {
240
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
241
+ try {
242
+ const result = runHook(
243
+ root,
244
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
245
+ activeState({ schemaVersion: 99 }),
246
+ );
247
+ assert.deepEqual(result.output, {});
248
+ } finally {
249
+ fs.rmSync(root, { recursive: true, force: true });
250
+ }
251
+ });
252
+
253
+ test("continues when the previous hook left state running", () => {
254
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
255
+ try {
256
+ const result = runHook(
257
+ root,
258
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
259
+ activeState({ state: "running", conversationId: "c1", iteration: 1, phaseIteration: 1 }),
260
+ );
261
+ assert.match(result.output.followup_message, /pass 2\/5/);
262
+ assert.equal(result.state.iteration, 2);
263
+ assert.equal(result.state.state, "running");
264
+ } finally {
265
+ fs.rmSync(root, { recursive: true, force: true });
266
+ }
267
+ });
268
+
269
+ test("resolves runtime state from workspace_roots", () => {
270
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
271
+ const repo = path.join(root, "repo");
272
+ const runtimeDir = path.join(repo, ORIS_FLOW_RUNTIME);
273
+ fs.mkdirSync(runtimeDir, { recursive: true });
274
+ const statePath = path.join(runtimeDir, "chat-active.json");
275
+ try {
276
+ fs.writeFileSync(statePath, `${JSON.stringify(activeState())}\n`, "utf8");
277
+ const result = spawnSync(process.execPath, [hook], {
278
+ input: JSON.stringify({
279
+ conversation_id: "c1",
280
+ status: "completed",
281
+ loop_count: 0,
282
+ workspace_roots: [repo],
283
+ }),
284
+ encoding: "utf8",
285
+ cwd: root,
286
+ });
287
+ assert.equal(result.status, 0, result.stderr);
288
+ const output = JSON.parse(result.stdout.trim());
289
+ assert.match(output.followup_message, /ORIS LOOP RUN/);
290
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
291
+ assert.equal(state.conversationId, "c1");
292
+ } finally {
293
+ fs.rmSync(root, { recursive: true, force: true });
294
+ }
295
+ });
296
+
297
+ test("advance uses the same conversation with phase instruction", () => {
298
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
299
+ try {
300
+ const result = runHook(
301
+ root,
302
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
303
+ activeState({ state: "advance", conversationId: "c1" }),
304
+ );
305
+ assert.match(result.output.followup_message, /advance to the next declared phase/);
306
+ } finally {
307
+ fs.rmSync(root, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("writes debug reason when conversation id is missing", () => {
312
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
313
+ try {
314
+ const result = runHook(
315
+ root,
316
+ { status: "completed", loop_count: 0 },
317
+ activeState(),
318
+ );
319
+ assert.deepEqual(result.output, {});
320
+ const debugPath = path.join(root, "hook-debug.jsonl");
321
+ assert.ok(fs.existsSync(debugPath));
322
+ const lines = fs.readFileSync(debugPath, "utf8").trim().split("\n");
323
+ const last = JSON.parse(lines[lines.length - 1]);
324
+ assert.equal(last.decision, "noop");
325
+ assert.equal(last.reason, "missing-conversation-id");
326
+ } finally {
327
+ fs.rmSync(root, { recursive: true, force: true });
328
+ }
329
+ });
330
+
331
+ test("writes debug reason when status is aborted", () => {
332
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
333
+ try {
334
+ runHook(
335
+ root,
336
+ { conversation_id: "c1", status: "aborted", loop_count: 0 },
337
+ activeState(),
338
+ );
339
+ const debugPath = path.join(root, "hook-debug.jsonl");
340
+ const last = JSON.parse(fs.readFileSync(debugPath, "utf8").trim().split("\n").at(-1));
341
+ assert.equal(last.reason, "status-not-completed:aborted");
342
+ } finally {
343
+ fs.rmSync(root, { recursive: true, force: true });
344
+ }
345
+ });
346
+
347
+ test("parses hook stdin with UTF-8 BOM from Cursor on Windows", () => {
348
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
349
+ const statePath = path.join(root, "chat-active.json");
350
+ try {
351
+ fs.writeFileSync(statePath, `${JSON.stringify(activeState())}\n`, "utf8");
352
+ const payload = JSON.stringify({
353
+ conversation_id: "c1",
354
+ status: "completed",
355
+ loop_count: 0,
356
+ });
357
+ const result = spawnSync(process.execPath, [hook], {
358
+ input: `${payload}`,
359
+ encoding: "utf8",
360
+ env: { ...process.env, ORIS_LOOP_CHAT_STATE: statePath },
361
+ });
362
+ assert.equal(result.status, 0, result.stderr);
363
+ const output = JSON.parse(result.stdout.trim());
364
+ assert.match(output.followup_message, /ORIS LOOP RUN/);
365
+ const debugPath = path.join(root, "hook-debug.jsonl");
366
+ const last = JSON.parse(fs.readFileSync(debugPath, "utf8").trim().split("\n").at(-1));
367
+ assert.equal(last.decision, "followup");
368
+ } finally {
369
+ fs.rmSync(root, { recursive: true, force: true });
370
+ }
371
+ });