oris-skills 2.0.0 → 2.2.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 (83) hide show
  1. package/.cursor-plugin/plugin.json +2 -11
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +79 -31
  4. package/agents/oris-loop-debriefer.md +1 -0
  5. package/agents/oris-loop-doctor.md +1 -1
  6. package/agents/oris-loop-executor.md +5 -2
  7. package/agents/oris-loop-verifier.md +6 -2
  8. package/docs/architecture.md +9 -7
  9. package/docs/distribution.md +1 -1
  10. package/docs/maintainer-guide.md +5 -5
  11. package/docs/user-guide.md +12 -9
  12. package/package.json +2 -3
  13. package/references/clean-code-checklist.md +20 -107
  14. package/references/conventions.md +43 -3
  15. package/references/doc-policy.md +19 -3
  16. package/references/loop-contract.md +24 -17
  17. package/references/loop.schema.json +13 -0
  18. package/references/repo-map.md +3 -3
  19. package/references/repo-map.schema.json +41 -0
  20. package/references/settings.md +2 -1
  21. package/references/settings.schema.json +5 -0
  22. package/scripts/flow/oris-flow-layout.mjs +1 -0
  23. package/scripts/flow/oris-flow-scan.mjs +265 -27
  24. package/scripts/install/generate-agent-adapters.mjs +19 -2
  25. package/scripts/install/install-user-skills.mjs +61 -3
  26. package/scripts/install/uninstall-user-skills.mjs +17 -1
  27. package/scripts/loop/oris-loop-bootstrap.mjs +7 -2
  28. package/scripts/loop/oris-loop-chat.mjs +34 -16
  29. package/scripts/loop/oris-loop-demo.mjs +11 -20
  30. package/scripts/loop/oris-loop-document.mjs +24 -7
  31. package/scripts/loop/oris-loop-dry-run.mjs +8 -4
  32. package/scripts/loop/oris-loop-fixtures.mjs +13 -12
  33. package/scripts/loop/oris-loop-run.mjs +80 -20
  34. package/scripts/loop/oris-loop-stop.mjs +48 -9
  35. package/scripts/loop/oris-loop-templates.mjs +13 -10
  36. package/scripts/loop/oris-loop-verify.mjs +1 -2
  37. package/scripts/oris-skills.mjs +3 -2
  38. package/scripts/tests/run-all-tests.mjs +1 -2
  39. package/scripts/tests/test-oris-flow-scan.mjs +109 -1
  40. package/scripts/tests/test-oris-loop-document.mjs +39 -3
  41. package/scripts/tests/test-oris-loop-run.mjs +28 -2
  42. package/scripts/tests/test-oris-loop-smoke.mjs +36 -3
  43. package/scripts/tests/test-oris-loop-stop.mjs +65 -4
  44. package/scripts/tests/test-routing-lifecycle.mjs +38 -23
  45. package/scripts/tests/test-skill-style.mjs +64 -0
  46. package/skills/oris-flow/SKILL.md +53 -25
  47. package/skills/oris-flow/references/architecture.md +77 -0
  48. package/skills/oris-flow/references/change.md +60 -0
  49. package/skills/oris-flow/references/criteria.md +60 -0
  50. package/skills/oris-flow/references/discover.md +66 -0
  51. package/skills/oris-flow/references/docs.md +33 -0
  52. package/skills/oris-flow/references/fix.md +49 -0
  53. package/skills/oris-flow/references/help.md +34 -0
  54. package/skills/oris-flow/references/implement.md +48 -0
  55. package/skills/oris-flow/references/loop-craft.md +59 -0
  56. package/skills/oris-flow/references/loop-improve.md +32 -0
  57. package/skills/oris-flow/references/loop-run.md +47 -0
  58. package/skills/oris-flow/references/loop.md +56 -0
  59. package/skills/oris-flow/references/new.md +64 -0
  60. package/skills/oris-flow/references/plan.md +58 -0
  61. package/skills/oris-flow/references/setup.md +100 -0
  62. package/skills/oris-flow/references/verify.md +57 -0
  63. package/skills/{oris-loop → oris-flow}/templates/debriefer.md +2 -1
  64. package/skills/oris-flow/templates/doctor.md +22 -0
  65. package/skills/{oris-loop → oris-flow}/templates/executor.md +8 -2
  66. package/skills/{oris-loop → oris-flow}/templates/orchestrator.md +10 -7
  67. package/skills/{oris-loop → oris-flow}/templates/verifier.md +6 -3
  68. package/references/questions.md +0 -38
  69. package/skills/oris-flow-criteria/SKILL.md +0 -49
  70. package/skills/oris-flow-discover/SKILL.md +0 -58
  71. package/skills/oris-flow-docs/SKILL.md +0 -31
  72. package/skills/oris-flow-fix/SKILL.md +0 -42
  73. package/skills/oris-flow-implement/SKILL.md +0 -43
  74. package/skills/oris-flow-plan/SKILL.md +0 -51
  75. package/skills/oris-flow-setup/SKILL.md +0 -49
  76. package/skills/oris-help/SKILL.md +0 -30
  77. package/skills/oris-help/agents/openai.yaml +0 -4
  78. package/skills/oris-loop/SKILL.md +0 -66
  79. package/skills/oris-loop/agents/openai.yaml +0 -4
  80. package/skills/oris-loop/references/craft.md +0 -48
  81. package/skills/oris-loop/references/improve.md +0 -23
  82. package/skills/oris-loop/references/run.md +0 -30
  83. package/skills/oris-loop/templates/doctor.md +0 -20
@@ -16,6 +16,7 @@ import process from "node:process";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { readAdapter, resolveLoopDocumentPathsBySlug } from "./oris-loop-paths.mjs";
18
18
  import {
19
+ activeRoles,
19
20
  assessPromptFiles,
20
21
  improveMode,
21
22
  normalizeModels,
@@ -64,21 +65,24 @@ function getRoot(options) {
64
65
  return path.resolve(result.stdout.trim());
65
66
  }
66
67
 
68
+ /** Roles flagged readonly must not get write access at the harness level. */
69
+ const READONLY_ROLES = new Set(["verifier", "doctor"]);
70
+
67
71
  /** Build the CLI invocation for one role. Exported for tests. */
68
- export function buildRoleInvocation(agent, { prompt, model, root }) {
72
+ export function buildRoleInvocation(agent, { prompt, model, root, readonly = false }) {
69
73
  if (agent === "claude") {
70
- const args = ["-p", prompt, "--permission-mode", "acceptEdits"];
74
+ const args = ["-p", prompt, "--permission-mode", readonly ? "plan" : "acceptEdits"];
71
75
  if (model && model !== "inherit") args.push("--model", model);
72
76
  return { command: "claude", args, cwd: root };
73
77
  }
74
- const args = ["exec", "--sandbox", "workspace-write", "--cd", root];
78
+ const args = ["exec", "--sandbox", readonly ? "read-only" : "workspace-write", "--cd", root];
75
79
  if (model && model !== "inherit") args.push("-m", model);
76
80
  args.push(prompt);
77
81
  return { command: "codex", args, cwd: root };
78
82
  }
79
83
 
80
84
  function runRole(agent, role, prompt, model, root, evidenceDir, pass) {
81
- const invocation = buildRoleInvocation(agent, { prompt, model, root });
85
+ const invocation = buildRoleInvocation(agent, { prompt, model, root, readonly: READONLY_ROLES.has(role) });
82
86
  process.stdout.write(` ${role} (${agent}${model && model !== "inherit" ? `, ${model}` : ""})... `);
83
87
  const result = childProcess.spawnSync(invocation.command, invocation.args, {
84
88
  cwd: invocation.cwd,
@@ -101,6 +105,15 @@ function runRole(agent, role, prompt, model, root, evidenceDir, pass) {
101
105
  /** Pull the last JSON object out of an agent's free-form output. */
102
106
  export function extractJson(output) {
103
107
  const text = String(output ?? "");
108
+ // Prefer the last ```json fenced block — the format the role prompts request.
109
+ const fenced = [...text.matchAll(/```json\s*\n?([\s\S]*?)```/gi)];
110
+ for (let i = fenced.length - 1; i >= 0; i -= 1) {
111
+ try {
112
+ return JSON.parse(fenced[i][1]);
113
+ } catch {
114
+ // fall through to the brace scan
115
+ }
116
+ }
104
117
  for (let start = text.lastIndexOf("{"); start !== -1; start = start === 0 ? -1 : text.lastIndexOf("{", start - 1)) {
105
118
  for (let end = text.indexOf("}", start); end !== -1; end = text.indexOf("}", end + 1)) {
106
119
  try {
@@ -113,14 +126,35 @@ export function extractJson(output) {
113
126
  return null;
114
127
  }
115
128
 
129
+ /**
130
+ * Canonical doctor decisions (references/loop-contract.md):
131
+ * continue | advance | complete | ask-user | blocked | propose-patch.
132
+ * A proposed patch needs user approval, so it pauses like ask-user.
133
+ * Unknown decisions block — but loudly, never silently.
134
+ */
116
135
  export function doctorDecisionToState(decision) {
117
136
  const value = String(decision ?? "").toLowerCase();
118
137
  if (value === "complete") return "complete";
119
138
  if (value === "advance") return "advance";
120
139
  if (value === "continue" || value === "retry") return "continue";
140
+ if (value === "ask-user" || value === "ask_user" || value === "propose-patch") return "ask-user";
141
+ if (value !== "blocked" && value !== "block" && value !== "stop") {
142
+ console.warn(` WARN unrecognized doctor decision "${decision}" — treating as blocked.`);
143
+ }
121
144
  return "blocked";
122
145
  }
123
146
 
147
+ /**
148
+ * Without a doctor, the verifier verdict drives the state directly:
149
+ * pass + goalMet → complete; pass|fail → continue; anything else pauses.
150
+ */
151
+ export function verifierVerdictToState(verifierResult) {
152
+ const state = String(verifierResult?.state ?? "").toLowerCase();
153
+ if (state === "pass") return verifierResult?.goalMet === true ? "complete" : "continue";
154
+ if (state === "fail") return "continue";
155
+ return "ask-user";
156
+ }
157
+
124
158
  function chatAction(root, args) {
125
159
  const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "oris-loop-chat.mjs");
126
160
  const result = childProcess.spawnSync(process.execPath, [script, "--repository-root", root, ...args], {
@@ -156,7 +190,8 @@ try {
156
190
  fail(error.message);
157
191
  }
158
192
  const metadata = loopDocument.data;
159
- const prompts = assessPromptFiles(path.dirname(docs.loopFull));
193
+ const roles = activeRoles(metadata);
194
+ const prompts = assessPromptFiles(path.dirname(docs.loopFull), roles);
160
195
  if (prompts.missing.length > 0) fail(`Loop prompt files are missing: ${prompts.missing.join(", ")}`);
161
196
  const models = normalizeModels(metadata.models, adapter.models);
162
197
  const mode = improveMode(metadata);
@@ -166,9 +201,11 @@ const receiptsDir = path.join(path.dirname(docs.loopFull), "receipts");
166
201
  fs.mkdirSync(evidenceDir, { recursive: true });
167
202
  fs.mkdirSync(receiptsDir, { recursive: true });
168
203
 
169
- console.log(`Oris loop headless run: ${metadata.name} (agent: ${options.agent}, improve: ${mode})`);
204
+ console.log(`Oris loop headless run: ${metadata.name} (agent: ${options.agent}, roles: ${roles.join("+")}, improve: ${mode})`);
170
205
  chatAction(root, ["--action", "start", "--loop", options.loopSlug, "--runner", "headless"]);
171
206
 
207
+ const maxNoProgress = Number(metadata.limits.maxNoProgress ?? 0);
208
+ let noProgressStreak = 0;
172
209
  let state = "continue";
173
210
  for (let pass = 1; pass <= maxPasses && (state === "continue" || state === "advance"); pass += 1) {
174
211
  console.log(`Pass ${pass}/${maxPasses}:`);
@@ -191,20 +228,35 @@ for (let pass = 1; pass <= maxPasses && (state === "continue" || state === "adva
191
228
  models.verifier, root, evidenceDir, pass);
192
229
  const verifierResult = extractJson(verifier.output);
193
230
 
194
- const doctor = runRole(options.agent, "doctor",
195
- rolePrompt(prompts.promptsDir, "doctor.md", runtimeValues,
196
- `EXECUTOR:\n${JSON.stringify(executorResult, null, 2)}\nVERIFIER:\n${JSON.stringify(verifierResult, null, 2)}`),
197
- models.doctor, root, evidenceDir, pass);
198
- const doctorResult = extractJson(doctor.output);
199
- state = doctorDecisionToState(doctorResult?.decision);
231
+ let doctor = null;
232
+ let doctorResult = null;
233
+ if (roles.includes("doctor")) {
234
+ doctor = runRole(options.agent, "doctor",
235
+ rolePrompt(prompts.promptsDir, "doctor.md", runtimeValues,
236
+ `EXECUTOR:\n${JSON.stringify(executorResult, null, 2)}\nVERIFIER:\n${JSON.stringify(verifierResult, null, 2)}`),
237
+ models.doctor, root, evidenceDir, pass);
238
+ doctorResult = extractJson(doctor.output);
239
+ if (doctorResult === null) {
240
+ console.warn(" WARN doctor output was not parseable JSON — pausing for user review.");
241
+ state = "ask-user";
242
+ } else {
243
+ state = doctorDecisionToState(doctorResult.decision);
244
+ }
245
+ } else {
246
+ state = verifierVerdictToState(verifierResult);
247
+ }
200
248
 
201
- if (mode === "auto" && fs.existsSync(path.join(prompts.promptsDir, "debriefer.md"))) {
249
+ if (roles.includes("debriefer") && mode === "auto" && fs.existsSync(path.join(prompts.promptsDir, "debriefer.md"))) {
202
250
  runRole(options.agent, "debriefer",
203
251
  rolePrompt(prompts.promptsDir, "debriefer.md", { ...runtimeValues, IMPROVE_MODE: mode },
204
- `DOCTOR VERDICT:\n${JSON.stringify(doctorResult, null, 2)}\nPROMPTS DIR: ${docs.promptsRelative}`),
252
+ `VERDICT:\n${JSON.stringify(doctorResult ?? verifierResult, null, 2)}\nPROMPTS DIR: ${docs.promptsRelative}`),
205
253
  models.debriefer, root, evidenceDir, pass);
206
254
  }
207
255
 
256
+ const progressed = verifierResult?.state === "pass"
257
+ || (Array.isArray(executorResult?.changedFiles) && executorResult.changedFiles.length > 0);
258
+ noProgressStreak = progressed ? 0 : noProgressStreak + 1;
259
+
208
260
  const receiptPath = path.join(receiptsDir, `pass-${String(pass).padStart(3, "0")}.md`);
209
261
  fs.writeFileSync(receiptPath, `# Oris Loop Receipt
210
262
 
@@ -215,16 +267,20 @@ Result: ${state}
215
267
 
216
268
  Executor: ${executorResult?.summary ?? executorResult?.claim ?? "see evidence"}
217
269
  Verifier: ${verifierResult?.state ?? "unknown"} — ${verifierResult?.summary ?? "see evidence"}
218
- Doctor: ${doctorResult?.decision ?? "unknown"} — ${doctorResult?.diagnosis ?? "see evidence"}
219
-
270
+ ${doctor ? `Doctor: ${doctorResult?.decision ?? "unknown"} — ${doctorResult?.diagnosis ?? "see evidence"}\n` : ""}
220
271
  Evidence:
221
272
  - ${path.relative(root, executor.evidencePath).replace(/\\/g, "/")}
222
273
  - ${path.relative(root, verifier.evidencePath).replace(/\\/g, "/")}
223
- - ${path.relative(root, doctor.evidencePath).replace(/\\/g, "/")}
224
- `, "utf8");
274
+ ${doctor ? `- ${path.relative(root, doctor.evidencePath).replace(/\\/g, "/")}\n` : ""}`, "utf8");
225
275
 
226
- chatAction(root, ["--action", "set-state", "--state", state === "advance" ? "advance" : state]);
227
- console.log(` state: ${state} | receipt: ${path.relative(root, receiptPath).replace(/\\/g, "/")}`);
276
+ chatAction(root, ["--action", "set-state", "--state", state === "advance" ? "advance" : state,
277
+ "--progress", progressed ? "yes" : "no"]);
278
+ console.log(` state: ${state} | progress: ${progressed ? "yes" : "no"} | receipt: ${path.relative(root, receiptPath).replace(/\\/g, "/")}`);
279
+
280
+ if (maxNoProgress >= 1 && noProgressStreak >= maxNoProgress && (state === "continue" || state === "advance")) {
281
+ state = "blocked";
282
+ console.log(`No-progress limit exhausted (${maxNoProgress} passes without progress) — state set to blocked.`);
283
+ }
228
284
  }
229
285
 
230
286
  if (state === "continue" || state === "advance") {
@@ -232,6 +288,10 @@ if (state === "continue" || state === "advance") {
232
288
  console.log("Pass budget exhausted before completion — state set to blocked.");
233
289
  process.exit(1);
234
290
  }
291
+ if (state === "ask-user") {
292
+ console.log("Headless run paused: the loop needs user input or approval. Review the last receipt, then resume with: npx oris-skills loop run --loop " + metadata.name);
293
+ process.exit(1);
294
+ }
235
295
  console.log(`Headless run finished: ${state}`);
236
296
  process.exit(state === "complete" ? 0 : 1);
237
297
  }
@@ -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),
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  /**
6
- * Prompt templates live in skills/oris-loop/templates/*.md so users and
6
+ * Prompt templates live in skills/oris-flow/templates/*.md so users and
7
7
  * agents can read and copy them. Scripts fall back to an embedded
8
8
  * orchestrator prompt so the stop hook can never fail on a missing file.
9
9
  */
@@ -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,17 +38,17 @@ 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));
46
49
  const candidates = [
47
- path.resolve(scriptsLoop, "..", "..", "skills", "oris-loop", "templates"),
48
- path.resolve(scriptsLoop, "..", "skills", "oris-loop", "templates"),
50
+ path.resolve(scriptsLoop, "..", "..", "skills", "oris-flow", "templates"),
51
+ path.resolve(scriptsLoop, "..", "skills", "oris-flow", "templates"),
49
52
  ];
50
53
  for (const candidate of candidates) {
51
54
  if (fs.existsSync(path.join(candidate, "orchestrator.md"))) return candidate;
@@ -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
  }
@@ -32,7 +32,7 @@ function printHelp() {
32
32
  console.log(`Oris Skills
33
33
 
34
34
  Install for your agents (Cursor, Claude Code, Codex):
35
- oris-skills install [--agents cursor,claude,codex] [--dry-run]
35
+ oris-skills install [--agents cursor,claude,codex,copilot] [--dry-run]
36
36
  oris-skills uninstall [--dry-run]
37
37
  oris-skills reinstall [--dry-run]
38
38
 
@@ -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") {
@@ -14,6 +14,7 @@ const nodeTestFiles = [
14
14
  "scripts/tests/test-oris-1-0-cleanliness.mjs",
15
15
  "scripts/tests/test-oris-flow-scan.mjs",
16
16
  "scripts/tests/test-routing-lifecycle.mjs",
17
+ "scripts/tests/test-skill-style.mjs",
17
18
  "scripts/tests/test-oris-loop-stop.mjs",
18
19
  "scripts/tests/test-oris-loop-smoke.mjs",
19
20
  "scripts/tests/test-oris-loop-bootstrap.mjs",
@@ -33,6 +34,4 @@ function run(command, args, options = {}) {
33
34
  if (result.status !== 0) process.exit(result.status ?? 1);
34
35
  }
35
36
 
36
- const nodeOnly = process.argv.includes("--node-only");
37
37
  run(process.execPath, ["--test", ...nodeTestFiles]);
38
-
@@ -3,7 +3,8 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
- import { scanRepository, writeScanResult } from "../flow/oris-flow-scan.mjs";
6
+ import childProcess from "node:child_process";
7
+ import { checkFreshness, scanRepository, writeScanResult } from "../flow/oris-flow-scan.mjs";
7
8
  import { ORIS_FLOW_MANIFEST } from "../flow/oris-flow-layout.mjs";
8
9
 
9
10
  function write(filePath, content) {
@@ -47,3 +48,110 @@ test("scanner supports documentation-only repositories", () => {
47
48
  }
48
49
  });
49
50
 
51
+ test("scanner detects project type and derives inferred stack commands", () => {
52
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
53
+ try {
54
+ write(path.join(root, "App.sln"), "");
55
+ write(path.join(root, "src", "App", "App.csproj"), "<Project />\n");
56
+ write(path.join(root, "src", "App", "Controllers", "HomeController.cs"), "class HomeController {}\n");
57
+
58
+ const scan = scanRepository(root);
59
+ assert.equal(scan.projectType.id, "api");
60
+ assert.ok(scan.stacks.some((stack) => stack.id === "dotnet"));
61
+ const dotnetTest = scan.commands.find((command) => command.command.startsWith("dotnet test"));
62
+ assert.ok(dotnetTest, "expected an inferred dotnet test command");
63
+ assert.equal(dotnetTest.confidence, "inferred");
64
+ assert.equal(scan.manifest.setup.tasksRoot, ".oris-flow/tasks");
65
+ } finally {
66
+ fs.rmSync(root, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test("refresh preserves confirmed sections, commands, and tasksRoot", () => {
71
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
72
+ try {
73
+ write(path.join(root, "README.md"), "# Demo\n");
74
+ write(path.join(root, "package.json"), `${JSON.stringify({ scripts: { test: "node --test" } })}\n`);
75
+
76
+ const first = scanRepository(root);
77
+ first.manifest.setup.tasksRoot = "docs/tasks";
78
+ first.manifest.setup.confirmedDecisions = ["tests live under scripts/tests"];
79
+ first.manifest.sections.docs.confidence = "confirmed";
80
+ first.manifest.commands[0].confidence = "confirmed";
81
+ writeScanResult(first);
82
+ const enriched = "# Docs\n\nUser-confirmed enrichment that must survive refresh.\n";
83
+ write(path.join(root, ".oris-flow", "maps", "docs.md"), enriched);
84
+
85
+ const second = scanRepository(root);
86
+ assert.equal(second.manifest.setup.tasksRoot, "docs/tasks");
87
+ assert.deepEqual(second.manifest.setup.confirmedDecisions, ["tests live under scripts/tests"]);
88
+ assert.equal(second.manifest.sections.docs.confidence, "confirmed");
89
+ assert.equal(second.manifest.sections.docs.stale, false);
90
+ assert.equal(second.manifest.commands[0].confidence, "confirmed");
91
+ assert.ok(second.preservedSections.has("docs"));
92
+
93
+ writeScanResult(second);
94
+ assert.equal(fs.readFileSync(path.join(root, ".oris-flow", "maps", "docs.md"), "utf8"), enriched);
95
+ } finally {
96
+ fs.rmSync(root, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test("confirmed section without fresh evidence is marked stale, not deleted", () => {
101
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
102
+ try {
103
+ write(path.join(root, "README.md"), "# Demo\n");
104
+ const first = scanRepository(root);
105
+ first.manifest.sections.docs.confidence = "confirmed";
106
+ writeScanResult(first);
107
+
108
+ fs.rmSync(path.join(root, "README.md"));
109
+ const second = scanRepository(root);
110
+ assert.equal(second.manifest.sections.docs.confidence, "confirmed");
111
+ assert.equal(second.manifest.sections.docs.stale, true);
112
+ } finally {
113
+ fs.rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+
118
+ test("scan anchors the map to HEAD; --check answers freshness from the SHA + changed files", () => {
119
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
120
+ const git = (...args) => childProcess.execFileSync("git", args, { cwd: root, encoding: "utf8" });
121
+ try {
122
+ write(path.join(root, "README.md"), "# Demo\n");
123
+ write(path.join(root, "package.json"), `${JSON.stringify({ scripts: { test: "node --test" } })}\n`);
124
+ git("init", "-q");
125
+ git("-c", "user.email=t@t", "-c", "user.name=t", "add", ".");
126
+ git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init");
127
+
128
+ const scan = scanRepository(root);
129
+ assert.equal(typeof scan.manifest.headSha, "string", "scan records the HEAD anchor");
130
+ writeScanResult(scan);
131
+
132
+ const fresh = checkFreshness(root);
133
+ assert.equal(fresh.fresh, true, "same SHA + clean tree → fresh, zero reads");
134
+ assert.deepEqual(fresh.changedFiles, []);
135
+
136
+ write(path.join(root, "src", "new.js"), "export const later = 1;\n");
137
+ const stale = checkFreshness(root);
138
+ assert.equal(stale.fresh, false, "working-tree change → stale");
139
+ assert.ok(stale.changedFiles.some((file) => file.includes("new.js")), "changed files name the delta");
140
+ } finally {
141
+ fs.rmSync(root, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("outside git the map still works — no anchor, never an error", () => {
146
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
147
+ try {
148
+ write(path.join(root, "README.md"), "# Demo\n");
149
+ const scan = scanRepository(root);
150
+ assert.equal(scan.manifest.headSha, null);
151
+ writeScanResult(scan);
152
+ const check = checkFreshness(root);
153
+ assert.equal(check.fresh, false, "no anchor → refresh advised, not an error");
154
+ } finally {
155
+ fs.rmSync(root, { recursive: true, force: true });
156
+ }
157
+ });
@@ -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
  }