oris-skills 2.0.0 → 2.1.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 (62) hide show
  1. package/.cursor-plugin/plugin.json +6 -1
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +11 -10
  4. package/agents/oris-loop-doctor.md +1 -1
  5. package/agents/oris-loop-verifier.md +3 -0
  6. package/docs/architecture.md +4 -3
  7. package/docs/distribution.md +1 -1
  8. package/docs/maintainer-guide.md +2 -2
  9. package/docs/user-guide.md +9 -7
  10. package/package.json +2 -3
  11. package/references/clean-code-checklist.md +20 -107
  12. package/references/conventions.md +16 -2
  13. package/references/doc-policy.md +19 -3
  14. package/references/loop-contract.md +23 -16
  15. package/references/loop.schema.json +13 -0
  16. package/references/repo-map.md +3 -3
  17. package/references/repo-map.schema.json +37 -0
  18. package/scripts/flow/oris-flow-layout.mjs +1 -0
  19. package/scripts/flow/oris-flow-scan.mjs +195 -27
  20. package/scripts/install/generate-agent-adapters.mjs +19 -2
  21. package/scripts/install/install-user-skills.mjs +7 -1
  22. package/scripts/loop/oris-loop-bootstrap.mjs +7 -2
  23. package/scripts/loop/oris-loop-chat.mjs +32 -14
  24. package/scripts/loop/oris-loop-demo.mjs +11 -20
  25. package/scripts/loop/oris-loop-document.mjs +24 -7
  26. package/scripts/loop/oris-loop-dry-run.mjs +7 -3
  27. package/scripts/loop/oris-loop-fixtures.mjs +12 -11
  28. package/scripts/loop/oris-loop-run.mjs +80 -20
  29. package/scripts/loop/oris-loop-stop.mjs +48 -9
  30. package/scripts/loop/oris-loop-templates.mjs +10 -7
  31. package/scripts/loop/oris-loop-verify.mjs +1 -2
  32. package/scripts/oris-skills.mjs +2 -1
  33. package/scripts/tests/run-all-tests.mjs +0 -2
  34. package/scripts/tests/test-oris-flow-scan.mjs +66 -0
  35. package/scripts/tests/test-oris-loop-document.mjs +39 -3
  36. package/scripts/tests/test-oris-loop-run.mjs +28 -2
  37. package/scripts/tests/test-oris-loop-smoke.mjs +36 -3
  38. package/scripts/tests/test-oris-loop-stop.mjs +65 -4
  39. package/scripts/tests/test-routing-lifecycle.mjs +11 -9
  40. package/skills/oris-flow/SKILL.md +16 -3
  41. package/skills/oris-flow-architecture/SKILL.md +64 -0
  42. package/skills/oris-flow-change/SKILL.md +55 -0
  43. package/skills/oris-flow-criteria/SKILL.md +10 -4
  44. package/skills/oris-flow-discover/SKILL.md +10 -5
  45. package/skills/oris-flow-docs/SKILL.md +2 -2
  46. package/skills/oris-flow-fix/SKILL.md +1 -1
  47. package/skills/oris-flow-implement/SKILL.md +2 -2
  48. package/skills/oris-flow-merge/SKILL.md +45 -0
  49. package/skills/oris-flow-new/SKILL.md +58 -0
  50. package/skills/oris-flow-plan/SKILL.md +8 -4
  51. package/skills/oris-flow-setup/SKILL.md +54 -23
  52. package/skills/oris-flow-verify/SKILL.md +50 -0
  53. package/skills/oris-help/SKILL.md +4 -4
  54. package/skills/oris-loop/SKILL.md +12 -32
  55. package/skills/oris-loop/references/craft.md +10 -9
  56. package/skills/oris-loop/references/run.md +9 -7
  57. package/skills/oris-loop/templates/debriefer.md +1 -1
  58. package/skills/oris-loop/templates/doctor.md +9 -7
  59. package/skills/oris-loop/templates/executor.md +3 -2
  60. package/skills/oris-loop/templates/orchestrator.md +10 -7
  61. package/skills/oris-loop/templates/verifier.md +4 -2
  62. package/references/questions.md +0 -38
@@ -85,6 +85,8 @@ function detectPlatform(argv, raw) {
85
85
  const explicit = argv.find((arg) => arg.startsWith("--platform="))?.slice("--platform=".length)
86
86
  ?? (argv.includes("--platform") ? argv[argv.indexOf("--platform") + 1] : "");
87
87
  if (explicit === "cursor" || explicit === "claude") return explicit;
88
+ // Cursor payloads carry conversation_id (and may ALSO carry session_id) — check it first.
89
+ if (raw.conversation_id !== undefined || raw.conversationId !== undefined) return "cursor";
88
90
  if (String(raw.hook_event_name ?? "") === "Stop" || raw.session_id !== undefined || raw.stop_hook_active !== undefined) {
89
91
  return "claude";
90
92
  }
@@ -146,10 +148,15 @@ function appendDebug(debugPath, entry) {
146
148
  }
147
149
  }
148
150
 
149
- function packageOutput(platform, followup) {
150
- if (!followup) return {};
151
- if (platform === "claude") return { decision: "block", reason: followup };
152
- return { followup_message: followup };
151
+ function packageOutput(platform, followup, notice) {
152
+ if (followup) {
153
+ if (platform === "claude") return { decision: "block", reason: followup };
154
+ return { followup_message: followup };
155
+ }
156
+ // A stopped/broken loop must never wedge the chat, but the user should hear
157
+ // about it. Claude Code shows systemMessage; Cursor has no non-resuming channel.
158
+ if (notice && platform === "claude") return { systemMessage: notice };
159
+ return {};
153
160
  }
154
161
 
155
162
  function finish(debugPath, baseEntry, result) {
@@ -160,7 +167,7 @@ function finish(debugPath, baseEntry, result) {
160
167
  reason: result.reason,
161
168
  emittedFollowup: Boolean(result.followup),
162
169
  });
163
- emit(packageOutput(baseEntry.platform, result.followup));
170
+ emit(packageOutput(baseEntry.platform, result.followup, result.notice));
164
171
  }
165
172
 
166
173
  /** Resolve the orchestrator prompt: per-loop file > packaged template > embedded. */
@@ -213,7 +220,11 @@ try {
213
220
  try {
214
221
  state = normalizeState(parseJsonText(fs.readFileSync(statePath, "utf8")));
215
222
  } catch {
216
- finish(debugPath, baseEntry, { decision: "noop", reason: "chat-active-invalid-json" });
223
+ finish(debugPath, baseEntry, {
224
+ decision: "noop",
225
+ reason: "chat-active-invalid-json",
226
+ notice: `Oris loop halted: ${statePath} is not valid JSON. Repair it with: npx oris-skills loop chat --action repair`,
227
+ });
217
228
  process.exit(0);
218
229
  }
219
230
 
@@ -222,7 +233,11 @@ try {
222
233
  baseEntry.runtimeIteration = state?.iteration ?? null;
223
234
 
224
235
  if (!state) {
225
- finish(debugPath, baseEntry, { decision: "noop", reason: "chat-active-unsupported-schema" });
236
+ finish(debugPath, baseEntry, {
237
+ decision: "noop",
238
+ reason: "chat-active-unsupported-schema",
239
+ notice: `Oris loop halted: ${statePath} uses an unsupported schema. Re-arm it with: npx oris-skills loop chat --action repair`,
240
+ });
226
241
  process.exit(0);
227
242
  }
228
243
 
@@ -260,13 +275,32 @@ try {
260
275
 
261
276
  if (iteration >= maxIterations || input.loopCount >= maxIterations) {
262
277
  stopState(statePath, state, "iteration limit exhausted");
263
- finish(debugPath, baseEntry, { decision: "stop", reason: "iteration-limit-exhausted" });
278
+ finish(debugPath, baseEntry, {
279
+ decision: "stop",
280
+ reason: "iteration-limit-exhausted",
281
+ notice: `Oris loop "${state.program ?? ""}" stopped: iteration limit exhausted (${maxIterations}). Goal NOT confirmed met. Resume with: npx oris-skills loop chat --action repair`,
282
+ });
264
283
  process.exit(0);
265
284
  }
266
285
 
267
286
  if (elapsedMinutes >= maxMinutes) {
268
287
  stopState(statePath, state, "wall-clock limit exhausted");
269
- finish(debugPath, baseEntry, { decision: "stop", reason: "wall-clock-limit-exhausted" });
288
+ finish(debugPath, baseEntry, {
289
+ decision: "stop",
290
+ reason: "wall-clock-limit-exhausted",
291
+ notice: `Oris loop "${state.program ?? ""}" stopped: wall-clock limit exhausted (${maxMinutes} min). Goal NOT confirmed met. Resume with: npx oris-skills loop chat --action repair`,
292
+ });
293
+ process.exit(0);
294
+ }
295
+
296
+ const maxNoProgress = Number(state.maxNoProgress ?? 0);
297
+ if (maxNoProgress >= 1 && Number(state.noProgressStreak ?? 0) >= maxNoProgress) {
298
+ stopState(statePath, state, "no-progress limit exhausted");
299
+ finish(debugPath, baseEntry, {
300
+ decision: "stop",
301
+ reason: "no-progress-limit-exhausted",
302
+ notice: `Oris loop "${state.program ?? ""}" stopped: ${maxNoProgress} passes in a row without progress. Goal NOT confirmed met.`,
303
+ });
270
304
  process.exit(0);
271
305
  }
272
306
 
@@ -280,6 +314,10 @@ try {
280
314
  ? state.repositoryRoot
281
315
  : input.workspaceRoots.find((root) => typeof root === "string" && fs.existsSync(root)) ?? process.cwd();
282
316
 
317
+ const roles = Array.isArray(state.roles) && state.roles.length > 0
318
+ ? state.roles
319
+ : ["executor", "verifier"];
320
+
283
321
  const followup = buildFollowup(state, repositoryRoot, {
284
322
  iteration: nextIteration,
285
323
  maxIterations,
@@ -291,6 +329,7 @@ try {
291
329
  loopPath: state.loopPath ?? "",
292
330
  contextPath: state.contextPath ?? "",
293
331
  promptsDir: state.promptsPath ?? "",
332
+ roles: roles.join(", "),
294
333
  executorModel: String(models.executor ?? defaultModel),
295
334
  verifierModel: String(models.verifier ?? defaultModel),
296
335
  doctorModel: String(models.doctor ?? defaultModel),
@@ -12,6 +12,7 @@ export const TEMPLATE_NAMES = ["orchestrator", "executor", "verifier", "doctor",
12
12
  export const EMBEDDED_ORCHESTRATOR = `ORIS LOOP RUN — pass {{iteration}}/{{maxIterations}}
13
13
  Loop: {{loop}} | Phase: {{phase}} ({{phaseIndex}}/{{phaseCount}}) | Do: {{instruction}}
14
14
  Definition: {{loopPath}} | Context: {{contextPath}} | Prompts: {{promptsDir}}
15
+ Active roles: {{roles}}
15
16
  Models: executor={{executorModel}} verifier={{verifierModel}} doctor={{doctorModel}} debriefer={{debrieferModel}} ("inherit" = current session model)
16
17
  Improve mode: {{improveMode}}
17
18
 
@@ -21,13 +22,15 @@ EACH PASS:
21
22
  1. READ {{contextPath}}; pick exactly ONE bounded next step.
22
23
  2. SPAWN executor subagent with {{promptsDir}}/executor.md (fill runtime values), model {{executorModel}}.
23
24
  3. SPAWN verifier subagent with {{promptsDir}}/verifier.md + the executor claim, model {{verifierModel}}.
24
- 4. SPAWN doctor subagent with {{promptsDir}}/doctor.md + both compact summaries, model {{doctorModel}}.
25
+ 4. DECIDE the state:
26
+ - doctor in active roles → SPAWN doctor subagent with {{promptsDir}}/doctor.md + both compact summaries, model {{doctorModel}}; use its decision.
27
+ - no doctor → map the verifier verdict: pass + goalMet → complete; pass → continue; fail → continue; blocked or inconclusive → ask-user.
25
28
  5. WRITE a compact receipt to the loop receipts/ folder; update context.md with durable facts only.
26
- 6. RUN: \`oris-skills loop chat --action set-state --state <continue|advance|complete|blocked>\`.
27
- 7. END the turn with exactly: \`ORIS_LOOP_STATE: continue | advance | complete | blocked | ask_user\`.
29
+ 6. RUN: \`npx oris-skills loop chat --action set-state --state <continue|advance|complete|blocked|ask-user> --progress <yes|no>\` (progress yes = the verifier confirmed forward movement).
30
+ 7. END the turn with exactly: \`ORIS_LOOP_STATE: <continue|advance|complete|blocked|ask-user>\`.
28
31
 
29
- IMPROVE ({{improveMode}}):
30
- - auto → after the doctor verdict, the debriefer may rewrite files under {{promptsDir}} (archive the old version to history/ first). It must NEVER change scope, limits, permissions, or verification in loop.md — those go to proposals/.
32
+ IMPROVE ({{improveMode}}, only when debriefer is in active roles):
33
+ - auto → after the verdict, the debriefer may rewrite files under {{promptsDir}} (archive the old version to history/ first). It must NEVER change scope, limits, permissions, or verification in loop.md — those go to proposals/.
31
34
  - propose → the debriefer writes proposals/ only; nothing changes until the user approves.
32
35
 
33
36
  ALWAYS:
@@ -35,11 +38,11 @@ ALWAYS:
35
38
  - Tell the user in one line what this pass will do before spawning workers.
36
39
 
37
40
  NEVER:
38
- - Continue when the doctor says blocked, scope is violated, or the same failure repeats.
41
+ - Continue when the verdict is blocked, scope is violated, or the same failure repeats.
39
42
  - Commit, push, deploy, edit .oris-flow/runtime/**, or touch paths outside the approved scope.
40
43
  - Ask "run next pass?" after continue/advance — end the turn; the hook schedules the next pass.
41
44
 
42
- IF prompts files are missing or subagents cannot be spawned: \`set-state --state blocked\`, say why, end the turn.`;
45
+ IF prompt files are missing or subagents cannot be spawned: \`set-state --state blocked\`, say why, end the turn.`;
43
46
 
44
47
  export function resolveTemplatesDir(fromImportMetaUrl) {
45
48
  const scriptsLoop = path.dirname(fileURLToPath(fromImportMetaUrl));
@@ -98,7 +98,6 @@ if (options.temp) {
98
98
  "--action", "start",
99
99
  "--task", task.taskPath,
100
100
  "--repository-root", repoRoot,
101
- "--max-repair-cycles", "0",
102
101
  ], { cwd: repoRoot, encoding: "utf8" });
103
102
  if (start.status !== 0) {
104
103
  console.error(start.stderr || start.stdout);
@@ -132,7 +131,7 @@ const chat = readJson(statePath);
132
131
  console.log("");
133
132
  console.log("chat-active.json:");
134
133
  if (!chat) {
135
- console.log("(missing - run oris-skills loop chat --action start first)");
134
+ console.log("(missing - run npx oris-skills loop chat --action start first)");
136
135
  } else {
137
136
  console.log(JSON.stringify(chat, null, 2));
138
137
  }
@@ -92,7 +92,8 @@ if (command === "doctor") {
92
92
  }
93
93
 
94
94
  if (command === "validate") {
95
- runNode("tests/run-all-tests.mjs", ["--node-only", ...commandArgs]);
95
+ // Kept as an alias of the full test suite for older docs and muscle memory.
96
+ runNode("tests/run-all-tests.mjs", []);
96
97
  }
97
98
 
98
99
  if (command === "loop") {
@@ -33,6 +33,4 @@ function run(command, args, options = {}) {
33
33
  if (result.status !== 0) process.exit(result.status ?? 1);
34
34
  }
35
35
 
36
- const nodeOnly = process.argv.includes("--node-only");
37
36
  run(process.execPath, ["--test", ...nodeTestFiles]);
38
-
@@ -47,3 +47,69 @@ test("scanner supports documentation-only repositories", () => {
47
47
  }
48
48
  });
49
49
 
50
+ test("scanner detects project type and derives inferred stack commands", () => {
51
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
52
+ try {
53
+ write(path.join(root, "App.sln"), "");
54
+ write(path.join(root, "src", "App", "App.csproj"), "<Project />\n");
55
+ write(path.join(root, "src", "App", "Controllers", "HomeController.cs"), "class HomeController {}\n");
56
+
57
+ const scan = scanRepository(root);
58
+ assert.equal(scan.projectType.id, "api");
59
+ assert.ok(scan.stacks.some((stack) => stack.id === "dotnet"));
60
+ const dotnetTest = scan.commands.find((command) => command.command.startsWith("dotnet test"));
61
+ assert.ok(dotnetTest, "expected an inferred dotnet test command");
62
+ assert.equal(dotnetTest.confidence, "inferred");
63
+ assert.equal(scan.manifest.setup.tasksRoot, ".oris-flow/tasks");
64
+ } finally {
65
+ fs.rmSync(root, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("refresh preserves confirmed sections, commands, and tasksRoot", () => {
70
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
71
+ try {
72
+ write(path.join(root, "README.md"), "# Demo\n");
73
+ write(path.join(root, "package.json"), `${JSON.stringify({ scripts: { test: "node --test" } })}\n`);
74
+
75
+ const first = scanRepository(root);
76
+ first.manifest.setup.tasksRoot = "docs/tasks";
77
+ first.manifest.setup.confirmedDecisions = ["tests live under scripts/tests"];
78
+ first.manifest.sections.docs.confidence = "confirmed";
79
+ first.manifest.commands[0].confidence = "confirmed";
80
+ writeScanResult(first);
81
+ const enriched = "# Docs\n\nUser-confirmed enrichment that must survive refresh.\n";
82
+ write(path.join(root, ".oris-flow", "maps", "docs.md"), enriched);
83
+
84
+ const second = scanRepository(root);
85
+ assert.equal(second.manifest.setup.tasksRoot, "docs/tasks");
86
+ assert.deepEqual(second.manifest.setup.confirmedDecisions, ["tests live under scripts/tests"]);
87
+ assert.equal(second.manifest.sections.docs.confidence, "confirmed");
88
+ assert.equal(second.manifest.sections.docs.stale, false);
89
+ assert.equal(second.manifest.commands[0].confidence, "confirmed");
90
+ assert.ok(second.preservedSections.has("docs"));
91
+
92
+ writeScanResult(second);
93
+ assert.equal(fs.readFileSync(path.join(root, ".oris-flow", "maps", "docs.md"), "utf8"), enriched);
94
+ } finally {
95
+ fs.rmSync(root, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("confirmed section without fresh evidence is marked stale, not deleted", () => {
100
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
101
+ try {
102
+ write(path.join(root, "README.md"), "# Demo\n");
103
+ const first = scanRepository(root);
104
+ first.manifest.sections.docs.confidence = "confirmed";
105
+ writeScanResult(first);
106
+
107
+ fs.rmSync(path.join(root, "README.md"));
108
+ const second = scanRepository(root);
109
+ assert.equal(second.manifest.sections.docs.confidence, "confirmed");
110
+ assert.equal(second.manifest.sections.docs.stale, true);
111
+ } finally {
112
+ fs.rmSync(root, { recursive: true, force: true });
113
+ }
114
+ });
115
+
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
+ activeRoles,
7
8
  assessPromptFiles,
8
9
  improveMode,
9
10
  normalizeModels,
@@ -16,6 +17,7 @@ kind: oris-loop
16
17
  name: bug-fix-loop
17
18
  approved: true
18
19
  phases: [observe, execute, verify, debrief]
20
+ roles: [executor, verifier, debriefer]
19
21
  limits:
20
22
  maxIterations: 10
21
23
  maxNoProgress: 2
@@ -79,6 +81,35 @@ test("rejects unknown improve modes", () => {
79
81
  );
80
82
  });
81
83
 
84
+ test("roles default to executor + verifier and must always include both", () => {
85
+ assert.deepEqual(activeRoles({}), ["executor", "verifier"]);
86
+ assert.deepEqual(activeRoles({ roles: ["executor", "verifier", "doctor"] }), ["executor", "verifier", "doctor"]);
87
+ assert.throws(
88
+ () => validateLoopDocumentText(
89
+ validLoop.replace("roles: [executor, verifier, debriefer]", "roles: [executor, debriefer]"),
90
+ "no-verifier.loop.md",
91
+ ),
92
+ /roles must include verifier/,
93
+ );
94
+ assert.throws(
95
+ () => validateLoopDocumentText(
96
+ validLoop.replace("roles: [executor, verifier, debriefer]", "roles: [executor, verifier, narrator]"),
97
+ "bad-role.loop.md",
98
+ ),
99
+ /roles must be an array/,
100
+ );
101
+ });
102
+
103
+ test("improve.mode auto requires the debriefer role", () => {
104
+ assert.throws(
105
+ () => validateLoopDocumentText(
106
+ validLoop.replace("roles: [executor, verifier, debriefer]", "roles: [executor, verifier]"),
107
+ "auto-without-debriefer.loop.md",
108
+ ),
109
+ /improve\.mode auto requires the debriefer role/,
110
+ );
111
+ });
112
+
82
113
  test("normalizeModels resolves inherit, adapter aliases, and role fallbacks", () => {
83
114
  const models = normalizeModels(
84
115
  { default: "inherit", verifier: "smart", doctor: "explicit-model-id" },
@@ -96,16 +127,21 @@ test("improveMode defaults to propose", () => {
96
127
  assert.equal(improveMode({ improve: { mode: "auto" } }), "auto");
97
128
  });
98
129
 
99
- test("assessPromptFiles reports missing role prompt files", () => {
130
+ test("assessPromptFiles reports missing prompts for the active roles only", () => {
100
131
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oris-prompts-"));
101
132
  try {
102
133
  const empty = assessPromptFiles(dir);
103
- assert.deepEqual(empty.missing, ["prompts/executor.md", "prompts/verifier.md", "prompts/doctor.md"]);
134
+ assert.deepEqual(empty.missing, ["prompts/executor.md", "prompts/verifier.md"]);
104
135
  fs.mkdirSync(path.join(dir, "prompts"), { recursive: true });
105
- for (const file of ["executor.md", "verifier.md", "doctor.md"]) {
136
+ for (const file of ["executor.md", "verifier.md"]) {
106
137
  fs.writeFileSync(path.join(dir, "prompts", file), "prompt content\n", "utf8");
107
138
  }
108
139
  assert.deepEqual(assessPromptFiles(dir).missing, []);
140
+ assert.deepEqual(
141
+ assessPromptFiles(dir, ["executor", "verifier", "doctor"]).missing,
142
+ ["prompts/doctor.md"],
143
+ "opting in a role requires its prompt file",
144
+ );
109
145
  } finally {
110
146
  fs.rmSync(dir, { recursive: true, force: true });
111
147
  }
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildRoleInvocation, doctorDecisionToState, extractJson } from "../loop/oris-loop-run.mjs";
3
+ import { buildRoleInvocation, doctorDecisionToState, extractJson, verifierVerdictToState } from "../loop/oris-loop-run.mjs";
4
4
  import { readTemplate, stripTemplateHeading, substitutePlaceholders } from "../loop/oris-loop-templates.mjs";
5
5
 
6
6
  test("buildRoleInvocation maps roles to codex and claude CLI calls with per-role models", () => {
@@ -16,21 +16,47 @@ test("buildRoleInvocation maps roles to codex and claude CLI calls with per-role
16
16
  assert.ok(!inherit.args.includes("--model"), "inherit must not pass an explicit model");
17
17
  });
18
18
 
19
+ test("readonly roles never get write access at the harness level", () => {
20
+ const claude = buildRoleInvocation("claude", { prompt: "check", model: "inherit", root: "/repo", readonly: true });
21
+ assert.ok(claude.args.includes("plan"), "claude readonly roles run in plan permission mode");
22
+ assert.ok(!claude.args.includes("acceptEdits"));
23
+
24
+ const codex = buildRoleInvocation("codex", { prompt: "check", model: "inherit", root: "/repo", readonly: true });
25
+ assert.ok(codex.args.includes("read-only"), "codex readonly roles run in the read-only sandbox");
26
+ assert.ok(!codex.args.includes("workspace-write"));
27
+ });
28
+
19
29
  test("extractJson pulls the last valid JSON object from noisy output", () => {
20
30
  const output = 'thinking...\n{"broken": \nand then {"role":"doctor","decision":"complete"} trailing';
21
31
  assert.deepEqual(extractJson(output), { role: "doctor", decision: "complete" });
22
32
  assert.equal(extractJson("no json here"), null);
23
33
  });
24
34
 
25
- test("doctorDecisionToState maps decisions to loop states", () => {
35
+ test("extractJson prefers the fenced json block over stray braces", () => {
36
+ const output = 'prose with {braces} everywhere\n```json\n{"role":"verifier","state":"pass","goalMet":true}\n```\nmore {noise}';
37
+ assert.deepEqual(extractJson(output), { role: "verifier", state: "pass", goalMet: true });
38
+ });
39
+
40
+ test("doctorDecisionToState maps the canonical decision enum to loop states", () => {
26
41
  assert.equal(doctorDecisionToState("complete"), "complete");
27
42
  assert.equal(doctorDecisionToState("continue"), "continue");
28
43
  assert.equal(doctorDecisionToState("retry"), "continue");
29
44
  assert.equal(doctorDecisionToState("advance"), "advance");
45
+ assert.equal(doctorDecisionToState("ask-user"), "ask-user", "ask-user must pause, never fail");
46
+ assert.equal(doctorDecisionToState("propose-patch"), "ask-user", "a proposed patch awaits approval");
30
47
  assert.equal(doctorDecisionToState("stop"), "blocked");
31
48
  assert.equal(doctorDecisionToState(undefined), "blocked");
32
49
  });
33
50
 
51
+ test("verifierVerdictToState drives two-role loops without a doctor", () => {
52
+ assert.equal(verifierVerdictToState({ state: "pass", goalMet: true }), "complete");
53
+ assert.equal(verifierVerdictToState({ state: "pass", goalMet: false }), "continue");
54
+ assert.equal(verifierVerdictToState({ state: "fail" }), "continue");
55
+ assert.equal(verifierVerdictToState({ state: "blocked" }), "ask-user");
56
+ assert.equal(verifierVerdictToState({ state: "inconclusive" }), "ask-user");
57
+ assert.equal(verifierVerdictToState(null), "ask-user", "unparseable verifier output pauses the loop");
58
+ });
59
+
34
60
  test("orchestrator template resolves from the skill templates and substitutes placeholders", () => {
35
61
  const template = readTemplate("orchestrator", import.meta.url);
36
62
  assert.match(template, /ORIS LOOP RUN/);
@@ -27,7 +27,7 @@ test("same-chat verify passes on temporary repository", () => {
27
27
  assert.match(result.stdout, /Same-chat verification passed\./);
28
28
  });
29
29
 
30
- test("phase advance does not consume repair-cycle limit", () => {
30
+ test("phase advance moves through declared phases", () => {
31
31
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-advance-"));
32
32
  try {
33
33
  const task = initSmokeRepository(root, {
@@ -36,7 +36,7 @@ test("phase advance does not consume repair-cycle limit", () => {
36
36
  phases: ["test", "fix", "clean"],
37
37
  });
38
38
  const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
39
- const common = ["--repository-root", root, "--task", task.taskPath, "--max-repair-cycles", "2"];
39
+ const common = ["--repository-root", root, "--task", task.taskPath];
40
40
  const start = spawnSync(process.execPath, [chat, "--action", "start", ...common], { encoding: "utf8" });
41
41
  assert.equal(start.status, 0, `${start.stdout}\n${start.stderr}`);
42
42
 
@@ -45,6 +45,7 @@ test("phase advance does not consume repair-cycle limit", () => {
45
45
  chat,
46
46
  "--action", "set-state",
47
47
  "--state", "advance",
48
+ "--progress", "yes",
48
49
  "--repository-root", root,
49
50
  ], { encoding: "utf8" });
50
51
  assert.equal(advance.status, 0, `${advance.stdout}\n${advance.stderr}`);
@@ -53,13 +54,45 @@ test("phase advance does not consume repair-cycle limit", () => {
53
54
  const state = JSON.parse(fs.readFileSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json"), "utf8"));
54
55
  assert.equal(state.active, true);
55
56
  assert.equal(state.state, "advance");
56
- assert.equal(state.repairCycles, 0);
57
+ assert.equal(state.noProgressStreak, 0);
58
+ assert.deepEqual(state.roles, ["executor", "verifier"]);
57
59
  assert.equal(state.phaseName, "clean");
58
60
  } finally {
59
61
  fs.rmSync(root, { recursive: true, force: true });
60
62
  }
61
63
  });
62
64
 
65
+ test("no-progress passes force the loop to blocked", () => {
66
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-noprog-"));
67
+ try {
68
+ const task = initSmokeRepository(root, {
69
+ taskSlug: "noprog-smoke",
70
+ program: "noprog-smoke",
71
+ });
72
+ const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
73
+ const start = spawnSync(process.execPath, [
74
+ chat, "--action", "start", "--repository-root", root, "--task", task.taskPath,
75
+ ], { encoding: "utf8" });
76
+ assert.equal(start.status, 0, `${start.stdout}\n${start.stderr}`);
77
+
78
+ // maxNoProgress is 2 in the smoke loop document.
79
+ for (let index = 0; index < 2; index += 1) {
80
+ const step = spawnSync(process.execPath, [
81
+ chat, "--action", "set-state", "--state", "continue", "--progress", "no", "--repository-root", root,
82
+ ], { encoding: "utf8" });
83
+ assert.equal(step.status, 0, `${step.stdout}\n${step.stderr}`);
84
+ }
85
+
86
+ const state = JSON.parse(fs.readFileSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json"), "utf8"));
87
+ assert.equal(state.state, "blocked");
88
+ assert.equal(state.active, false);
89
+ assert.equal(state.stopReason, "no-progress limit exhausted");
90
+ assert.equal(state.noProgressStreak, 2);
91
+ } finally {
92
+ fs.rmSync(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
63
96
  test("same-chat start accepts an existing loop slug", () => {
64
97
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-slug-"));
65
98
  try {
@@ -52,11 +52,12 @@ function activeState(overrides = {}) {
52
52
  doctor: "model-d",
53
53
  debriefer: "inherit",
54
54
  },
55
+ roles: ["executor", "verifier", "doctor"],
55
56
  conversationId: null,
56
57
  iteration: 0,
57
58
  maxIterations: 5,
58
- repairCycles: 0,
59
- maxRepairCycles: 2,
59
+ noProgressStreak: 0,
60
+ maxNoProgress: 2,
60
61
  maxMinutes: 30,
61
62
  startedAt: new Date().toISOString(),
62
63
  ...overrides,
@@ -91,13 +92,15 @@ test("cursor: claims the conversation and injects the orchestrator prompt", () =
91
92
  assert.match(message, /Definition: \.oris-flow\/loops\/sample\/loop\.md/);
92
93
  assert.match(message, /Prompts: \.oris-flow\/loops\/sample\/prompts/);
93
94
  assert.match(message, /executor=model-e verifier=model-v doctor=model-d debriefer=inherit/);
95
+ assert.match(message, /Active roles: executor, verifier, doctor/);
94
96
  assert.match(message, /Improve mode: auto/);
95
97
  assert.match(message, /ROLE: you are the orchestrator only/);
96
98
  assert.match(message, /SPAWN executor subagent/);
97
99
  assert.match(message, /SPAWN verifier subagent/);
98
100
  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.match(message, /npx oris-skills loop chat --action set-state --state/);
102
+ assert.match(message, /--progress <yes\|no>/);
103
+ assert.match(message, /ORIS_LOOP_STATE: <continue\|advance\|complete\|blocked\|ask-user>/);
101
104
  assert.doesNotMatch(message, /\{\{iteration\}\}/, "runtime placeholders must be substituted");
102
105
  assert.equal(result.state.conversationId, "c1");
103
106
  assert.equal(result.state.iteration, 1);
@@ -141,6 +144,23 @@ test("claude: platform is auto-detected from Stop payload", () => {
141
144
  }
142
145
  });
143
146
 
147
+ test("cursor: auto-detected even when the payload also carries session_id", () => {
148
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
149
+ try {
150
+ // Real Cursor payloads include BOTH conversation_id and session_id.
151
+ const result = runHook(
152
+ root,
153
+ { conversation_id: "c9", session_id: "s9", status: "completed", loop_count: 0 },
154
+ activeState(),
155
+ );
156
+ assert.ok(result.output.followup_message, "must answer in Cursor format");
157
+ assert.equal(result.output.decision, undefined);
158
+ assert.equal(result.state.conversationId, "c9");
159
+ } finally {
160
+ fs.rmSync(root, { recursive: true, force: true });
161
+ }
162
+ });
163
+
144
164
  test("per-loop prompts/orchestrator.md overrides the default template", () => {
145
165
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
146
166
  try {
@@ -214,6 +234,47 @@ test("stops on terminal state and exhausted limits", () => {
214
234
  );
215
235
  assert.deepEqual(expired.output, {});
216
236
  assert.equal(expired.state.state, "blocked");
237
+
238
+ const stalled = runHook(
239
+ root,
240
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
241
+ activeState({ conversationId: "c1", noProgressStreak: 2, maxNoProgress: 2 }),
242
+ );
243
+ assert.deepEqual(stalled.output, {});
244
+ assert.equal(stalled.state.state, "blocked");
245
+ assert.equal(stalled.state.stopReason, "no-progress limit exhausted");
246
+ } finally {
247
+ fs.rmSync(root, { recursive: true, force: true });
248
+ }
249
+ });
250
+
251
+ test("claude: stopping on an exhausted limit tells the user via systemMessage", () => {
252
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
253
+ try {
254
+ const result = runHook(
255
+ root,
256
+ { session_id: "s1", hook_event_name: "Stop", stop_hook_active: false, cwd: root },
257
+ activeState({ conversationId: "s1", iteration: 5, maxIterations: 5 }),
258
+ ["--platform", "claude"],
259
+ );
260
+ assert.match(result.output.systemMessage, /iteration limit exhausted/);
261
+ assert.equal(result.output.decision, undefined, "must not block the chat");
262
+ assert.equal(result.state.state, "blocked");
263
+ } finally {
264
+ fs.rmSync(root, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test("ask-user pauses the loop: the hook does not resume it", () => {
269
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-hook-"));
270
+ try {
271
+ const result = runHook(
272
+ root,
273
+ { conversation_id: "c1", status: "completed", loop_count: 0 },
274
+ activeState({ state: "ask-user", conversationId: "c1" }),
275
+ );
276
+ assert.deepEqual(result.output, {});
277
+ assert.equal(result.state.state, "ask-user", "pause must not be rewritten");
217
278
  } finally {
218
279
  fs.rmSync(root, { recursive: true, force: true });
219
280
  }
@@ -54,14 +54,13 @@ test("shared conventions centralize question, language, and gate rules", () => {
54
54
  assert.match(conventions, /ONE question per turn/i);
55
55
  assert.match(conventions, /Explain the options.*Spiega le opzioni/s);
56
56
  assert.match(conventions, /NEVER write documents, code, settings, Git, or DevOps without explicit user confirmation/i);
57
- const questions = read("references/questions.md");
58
- assert.match(questions, /`Explain the options`/);
59
- assert.match(questions, /`Spiega le opzioni`/);
60
- assert.match(questions, /`Basta domande — procedi`/);
61
- assert.match(questions, /`Genera il documento`/);
62
- assert.match(questions, /`Continua l'intervista`/);
63
- assert.match(questions, /`Approva documento`/);
64
- assert.match(questions, /`Annulla`/);
57
+ assert.match(conventions, /`Basta domande — procedi`/);
58
+ assert.match(conventions, /`Genera il documento`/);
59
+ assert.match(conventions, /`Continua l'intervista`/);
60
+ assert.match(conventions, /`Approva documento`/);
61
+ assert.match(conventions, /`Annulla`/);
62
+ assert.match(conventions, /oris-flow-criteria.*writes first/i, "the criteria write-first exception must live where skills read it");
63
+ assert.equal(exists("references/questions.md"), false, "questions.md was merged into conventions.md");
65
64
  });
66
65
 
67
66
  test("every skill points at conventions instead of repeating shared rules", () => {
@@ -108,10 +107,13 @@ test("loop contract stays subagent-first with per-role editable prompts", () =>
108
107
  const craft = read("skills/oris-loop/references/craft.md");
109
108
  assert.match(craft, /skills\/oris-loop\/templates\//);
110
109
  assert.match(craft, /write the loop \| edit verifier \| edit executor \| edit doctor/);
110
+ assert.match(contract, /ORIS LOOP ACTIVE/, "the contract defines the executor gate literal");
111
111
  for (const text of [contract, run, craft, read("skills/oris-loop/SKILL.md")]) {
112
112
  assert.doesNotMatch(text, /node scripts\/(?:flow|loop)\//);
113
113
  assert.doesNotMatch(text, /composer-2\.5/);
114
- assert.doesNotMatch(text, /ORIS LOOP ACTIVE/);
114
+ }
115
+ for (const text of [run, craft, read("skills/oris-loop/SKILL.md")]) {
116
+ assert.doesNotMatch(text, /ORIS LOOP ACTIVE/, "the gate literal lives in the contract and executor prompts only");
115
117
  }
116
118
  });
117
119