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
@@ -18,6 +18,7 @@ import {
18
18
  ORIS_SKILLS_SETTINGS,
19
19
  } from "../flow/oris-flow-layout.mjs";
20
20
  import {
21
+ activeRoles,
21
22
  assessPromptFiles,
22
23
  improveMode,
23
24
  normalizeModels,
@@ -36,10 +37,10 @@ function parseArgs(argv) {
36
37
  loopSlug: "",
37
38
  taskPath: "",
38
39
  state: "continue",
40
+ progress: "",
39
41
  repositoryRoot: "",
40
42
  runner: "chat",
41
43
  maxIterations: 25,
42
- maxRepairCycles: 10,
43
44
  maxMinutes: 240,
44
45
  profile: "",
45
46
  settingsPath: ORIS_SKILLS_SETTINGS,
@@ -56,6 +57,7 @@ function parseArgs(argv) {
56
57
  else if (key === "--loop" || key === "--loop-slug" || key.startsWith("--loop=") || key.startsWith("--loop-slug=")) [i, options.loopSlug] = readValue(i, raw);
57
58
  else if (key === "--task" || key === "--task-path" || key.startsWith("--task=") || key.startsWith("--task-path=")) [i, options.taskPath] = readValue(i, raw);
58
59
  else if (key === "--state" || key.startsWith("--state=")) [i, options.state] = readValue(i, raw);
60
+ else if (key === "--progress" || key.startsWith("--progress=")) [i, options.progress] = readValue(i, raw);
59
61
  else if (key === "--repository-root" || key.startsWith("--repository-root=")) [i, options.repositoryRoot] = readValue(i, raw);
60
62
  else if (key === "--runner" || key.startsWith("--runner=")) [i, options.runner] = readValue(i, raw);
61
63
  else if (key === "--profile" || key.startsWith("--profile=")) [i, options.profile] = readValue(i, raw);
@@ -64,10 +66,6 @@ function parseArgs(argv) {
64
66
  const parsed = readValue(i, raw);
65
67
  i = parsed[0];
66
68
  options.maxIterations = Number.parseInt(parsed[1], 10);
67
- } else if (key === "--max-repair-cycles" || key.startsWith("--max-repair-cycles=")) {
68
- const parsed = readValue(i, raw);
69
- i = parsed[0];
70
- options.maxRepairCycles = Number.parseInt(parsed[1], 10);
71
69
  } else if (key === "--max-minutes" || key.startsWith("--max-minutes=")) {
72
70
  const parsed = readValue(i, raw);
73
71
  i = parsed[0];
@@ -76,9 +74,11 @@ function parseArgs(argv) {
76
74
  }
77
75
  options.action = options.action.toLowerCase();
78
76
  options.state = options.state.toLowerCase();
77
+ options.progress = options.progress.toLowerCase();
79
78
  options.runner = options.runner.toLowerCase();
80
79
  if (!["start", "set-state", "stop", "status", "repair"].includes(options.action)) fail("Unsupported --action.");
81
- if (!["continue", "advance", "complete", "blocked", "cancelled"].includes(options.state)) fail("Unsupported --state.");
80
+ if (!["continue", "advance", "complete", "blocked", "cancelled", "ask-user"].includes(options.state)) fail("Unsupported --state.");
81
+ if (options.progress && !["yes", "no"].includes(options.progress)) fail("Unsupported --progress (yes|no).");
82
82
  if (!["chat", "headless"].includes(options.runner)) fail("Unsupported --runner (chat|headless).");
83
83
  return options;
84
84
  }
@@ -133,7 +133,7 @@ function reconcileContext(root, state) {
133
133
  "Current phase index": Number(state.phaseIndex ?? 0),
134
134
  "Current iteration": Number(state.iteration ?? 0),
135
135
  "Phase iteration": Number(state.phaseIteration ?? 0),
136
- "Repair cycles": Number(state.repairCycles ?? 0),
136
+ "No-progress streak": Number(state.noProgressStreak ?? 0),
137
137
  Updated: new Date().toISOString(),
138
138
  })) {
139
139
  content = setContextField(content, name, value);
@@ -213,7 +213,7 @@ function normalizeChatState(state) {
213
213
  normalized.phaseName = currentPhase(normalized);
214
214
  normalized.iteration = Number(normalized.iteration ?? 0);
215
215
  normalized.phaseIteration = Number(normalized.phaseIteration ?? 0);
216
- normalized.repairCycles = Number(normalized.repairCycles ?? 0);
216
+ normalized.noProgressStreak = Number(normalized.noProgressStreak ?? 0);
217
217
  normalized.phaseIndex = Number.isInteger(Number(normalized.phaseIndex)) ? Number(normalized.phaseIndex) : 0;
218
218
  if (normalized.state === "running" && normalized.active === true) {
219
219
  normalized.state = "continue";
@@ -283,13 +283,14 @@ if (options.action === "start") {
283
283
  } catch (error) {
284
284
  fail(error.message);
285
285
  }
286
- const prompts = assessPromptFiles(path.dirname(loopPath));
286
+ const prompts = assessPromptFiles(path.dirname(loopPath), activeRoles(loopDocument.data));
287
287
  if (prompts.missing.length > 0) {
288
288
  fail(`Loop prompt files are missing or empty: ${prompts.missing.join(", ")}. Craft or copy them from skills/oris-loop/templates/.`);
289
289
  }
290
290
  if (fs.existsSync(statePath)) {
291
291
  const existing = normalizeChatState(JSON.parse(fs.readFileSync(statePath, "utf8")));
292
- if (existing.active === true && !terminalStates.has(existing.state)) {
292
+ // ask-user is a pause awaiting input, not an active run restart may take over.
293
+ if (existing.active === true && !terminalStates.has(existing.state) && existing.state !== "ask-user") {
293
294
  fail(`An Oris chat loop is already active for this workspace: ${existing.taskPath}. Run --action stop before starting again.`);
294
295
  }
295
296
  }
@@ -310,13 +311,14 @@ if (options.action === "start") {
310
311
  loopPath: loopDocs.loopRelative,
311
312
  contextPath: loopDocs.contextRelative,
312
313
  promptsPath: loopDocs.promptsRelative,
314
+ roles: activeRoles(loopMetadata),
313
315
  models: normalizeModels(loopMetadata.models, adapter.models),
314
316
  improveMode: improveMode(loopMetadata),
315
317
  conversationId: null,
316
318
  iteration: 0,
317
319
  maxIterations: loopMetadata.limits.maxIterations,
318
- repairCycles: 0,
319
- maxRepairCycles: options.maxRepairCycles,
320
+ noProgressStreak: 0,
321
+ maxNoProgress: loopMetadata.limits.maxNoProgress,
320
322
  maxMinutes: loopMetadata.limits.maxMinutes ?? options.maxMinutes,
321
323
  startedAt: new Date().toISOString(),
322
324
  updatedAt: new Date().toISOString(),
@@ -348,6 +350,7 @@ if (options.action === "repair") {
348
350
  ) {
349
351
  current.active = true;
350
352
  current.state = "continue";
353
+ current.noProgressStreak = 0;
351
354
  current.startedAt = new Date().toISOString();
352
355
  delete current.stopReason;
353
356
  }
@@ -364,20 +367,32 @@ if (options.action === "set-state") {
364
367
  if (options.state === "advance") {
365
368
  current.phaseIndex = phases.length === 1 ? 0 : (Number(current.phaseIndex ?? 0) + 1) % phases.length;
366
369
  current.phaseIteration = 0;
367
- current.repairCycles = Number(current.repairCycles ?? 0);
368
370
  current.state = "advance";
369
371
  current.active = true;
370
372
  } else {
371
373
  current.state = options.state;
372
374
  current.active = !terminalStates.has(options.state);
373
375
  }
376
+ if (options.progress) {
377
+ current.noProgressStreak = options.progress === "yes" ? 0 : Number(current.noProgressStreak ?? 0) + 1;
378
+ }
379
+ const maxNoProgress = Number(current.maxNoProgress ?? 0);
380
+ if (
381
+ maxNoProgress >= 1
382
+ && Number(current.noProgressStreak ?? 0) >= maxNoProgress
383
+ && (current.state === "continue" || current.state === "advance")
384
+ ) {
385
+ current.state = "blocked";
386
+ current.active = false;
387
+ current.stopReason = "no-progress limit exhausted";
388
+ }
374
389
  current.phases = phases;
375
390
  current.phaseName = currentPhase(current);
376
391
  current.schemaVersion = 1;
377
392
  current.updatedAt = new Date().toISOString();
378
393
  writeJson(statePath, current);
379
394
  reconcileContext(root, current);
380
- console.log(`Oris chat loop state: ${current.state}`);
395
+ console.log(`Oris chat loop state: ${current.state}${current.stopReason ? ` (${current.stopReason})` : ""}`);
381
396
  process.exit(0);
382
397
  }
383
398
 
@@ -401,6 +416,9 @@ console.log(JSON.stringify({
401
416
  taskPath: current.taskPath,
402
417
  iteration: Number(current.iteration ?? 0),
403
418
  maxIterations: Number(current.maxIterations ?? 0),
419
+ noProgressStreak: Number(current.noProgressStreak ?? 0),
420
+ maxNoProgress: Number(current.maxNoProgress ?? 0),
421
+ roles: Array.isArray(current.roles) ? current.roles : ["executor", "verifier"],
404
422
  improveMode: current.improveMode ?? "propose",
405
423
  models: current.models ?? {},
406
424
  conversationBound: Boolean(current.conversationId),
@@ -73,6 +73,7 @@ kind: oris-loop
73
73
  name: ${DEMO_SLUG}
74
74
  approved: true
75
75
  phases: [work]
76
+ roles: [executor, verifier, debriefer]
76
77
  limits:
77
78
  maxIterations: 3
78
79
  maxNoProgress: 2
@@ -97,6 +98,7 @@ Stop: complete at 3; blocked if the file is missing or not a number.
97
98
  This loop exists to LEARN the loop system safely:
98
99
  - it only touches files inside its own folder;
99
100
  - it runs 3 short passes, then completes;
101
+ - roles are executor + verifier (the default) plus the debriefer;
100
102
  - improve.mode is \`auto\`, so you can watch the debriefer tune \`prompts/\` between passes.
101
103
 
102
104
  Try: edit \`prompts/executor.md\` and see the next pass obey your change.
@@ -104,13 +106,14 @@ Try: edit \`prompts/executor.md\` and see the next pass obey your change.
104
106
 
105
107
  fs.writeFileSync(path.join(loopDir, "prompts", "executor.md"), `# Executor prompt — demo loop (edit me and watch the next pass change)
106
108
 
109
+ ORIS LOOP ACTIVE — approved demo pass.
107
110
  You are the executor of the Oris demo loop.
108
111
 
109
112
  THIS PASS: iteration {{iteration}}.
110
113
  DO: read ${workspaceRelative}/counter.txt, add 1, write the new value back.
111
114
  ONLY touch files under ${workspaceRelative}.
112
115
 
113
- RETURN compact JSON only:
116
+ RETURN compact JSON only (\`\`\`json fenced):
114
117
  { "role": "executor", "state": "claimed|blocked", "summary": "", "changedFiles": [], "claim": "counter is now N" }
115
118
  `, "utf8");
116
119
 
@@ -120,22 +123,10 @@ You are the independent verifier of the Oris demo loop.
120
123
  NEVER trust the executor claim without reading the file yourself.
121
124
 
122
125
  VERIFY: ${workspaceRelative}/counter.txt contains exactly the claimed number.
126
+ goalMet = true ONLY when the verified counter equals 3 (the loop goal).
123
127
 
124
- RETURN compact JSON only:
125
- { "role": "verifier", "state": "pass|fail|blocked", "summary": "", "evidence": "file content: N" }
126
- `, "utf8");
127
-
128
- fs.writeFileSync(path.join(loopDir, "prompts", "doctor.md"), `# Doctor prompt — demo loop
129
-
130
- You are the doctor of the Oris demo loop.
131
-
132
- DECIDE from the verifier evidence:
133
- - counter < 3 and verified → continue
134
- - counter = 3 and verified → complete
135
- - verification failed or file broken → stop
136
-
137
- RETURN compact JSON only:
138
- { "role": "doctor", "decision": "continue|complete|stop", "diagnosis": "" }
128
+ RETURN compact JSON only (\`\`\`json fenced):
129
+ { "role": "verifier", "state": "pass|fail|blocked|inconclusive", "goalMet": false, "summary": "", "evidence": "file content: N" }
139
130
  `, "utf8");
140
131
 
141
132
  fs.writeFileSync(path.join(loopDir, "prompts", "debriefer.md"), `# Debriefer prompt — demo loop (improve mode: auto)
@@ -157,7 +148,7 @@ Current phase: work
157
148
  Current phase index: 0
158
149
  Current iteration: 0
159
150
  Phase iteration: 0
160
- Repair cycles: 0
151
+ No-progress streak: 0
161
152
  Updated: ${new Date().toISOString()}
162
153
 
163
154
  ## Objective
@@ -172,9 +163,9 @@ Increment the counter from its current value.
172
163
  console.log(`Demo loop created: ${docs.loopRelative}`);
173
164
  console.log("");
174
165
  console.log("Learn it in 3 steps:");
175
- console.log(` 1. Preview a pass (nothing runs): oris-skills loop dry-run --loop ${DEMO_SLUG}`);
176
- console.log(` 2. Run it from your agent chat: oris-skills loop chat --action start --loop ${DEMO_SLUG}`);
166
+ console.log(` 1. Preview a pass (nothing runs): npx oris-skills loop dry-run --loop ${DEMO_SLUG}`);
167
+ console.log(` 2. Run it from your agent chat: npx oris-skills loop chat --action start --loop ${DEMO_SLUG}`);
177
168
  console.log(" then end the turn — the stop hook schedules pass 1 automatically.");
178
169
  console.log(` 3. Watch and tune: edit ${docs.promptsRelative}/executor.md between passes.`);
179
170
  console.log("");
180
- console.log(`Stop anytime: oris-skills loop chat --action stop`);
171
+ console.log(`Stop anytime: npx oris-skills loop chat --action stop`);
@@ -7,8 +7,7 @@ const phasePattern = /^[a-z][a-z0-9-]*$/;
7
7
 
8
8
  export const INHERIT_MODEL = "inherit";
9
9
  export const ROLE_NAMES = ["executor", "verifier", "doctor", "debriefer"];
10
- export const REQUIRED_PROMPT_FILES = ["executor.md", "verifier.md", "doctor.md"];
11
- export const OPTIONAL_PROMPT_FILES = ["orchestrator.md", "debriefer.md"];
10
+ export const DEFAULT_ROLES = ["executor", "verifier"];
12
11
  export const IMPROVE_MODES = new Set(["propose", "auto"]);
13
12
 
14
13
  export function parseFrontMatter(text, filePath = "loop.md") {
@@ -97,6 +96,19 @@ export function validateLoopMetadata(data, filePath = "loop.md") {
97
96
  if (!Array.isArray(data.phases) || data.phases.length < 1 || data.phases.some((phase) => typeof phase !== "string" || !phasePattern.test(phase))) {
98
97
  errors.push("phases must contain valid phase slugs");
99
98
  }
99
+ if (data.roles !== undefined) {
100
+ if (!Array.isArray(data.roles) || data.roles.some((role) => !ROLE_NAMES.includes(role))) {
101
+ errors.push(`roles must be an array of ${ROLE_NAMES.join("|")}`);
102
+ } else {
103
+ if (new Set(data.roles).size !== data.roles.length) errors.push("roles must not repeat");
104
+ for (const required of DEFAULT_ROLES) {
105
+ if (!data.roles.includes(required)) errors.push(`roles must include ${required}`);
106
+ }
107
+ }
108
+ }
109
+ if (data.improve?.mode === "auto" && !activeRoles(data).includes("debriefer")) {
110
+ errors.push("improve.mode auto requires the debriefer role (roles: [executor, verifier, debriefer])");
111
+ }
100
112
  if (!data.limits || !Number.isInteger(data.limits.maxIterations) || data.limits.maxIterations < 1 || data.limits.maxIterations > 500) {
101
113
  errors.push("limits.maxIterations must be 1..500");
102
114
  }
@@ -162,13 +174,18 @@ export function improveMode(data) {
162
174
  return data?.improve?.mode === "auto" ? "auto" : "propose";
163
175
  }
164
176
 
165
- /** Check the per-loop prompts/ directory. Returns { promptsDir, missing }. */
166
- export function assessPromptFiles(loopDir) {
177
+ /** Roles a loop runs each pass; executor + verifier unless loop.md opts more in. */
178
+ export function activeRoles(data) {
179
+ return Array.isArray(data?.roles) && data.roles.length > 0 ? [...data.roles] : [...DEFAULT_ROLES];
180
+ }
181
+
182
+ /** Check the per-loop prompts/ directory against the loop's active roles. */
183
+ export function assessPromptFiles(loopDir, roles = DEFAULT_ROLES) {
167
184
  const promptsDir = path.join(loopDir, "prompts");
168
185
  const missing = [];
169
- for (const file of REQUIRED_PROMPT_FILES) {
170
- const full = path.join(promptsDir, file);
171
- if (!fs.existsSync(full) || !fs.readFileSync(full, "utf8").trim()) missing.push(`prompts/${file}`);
186
+ for (const role of roles) {
187
+ const full = path.join(promptsDir, `${role}.md`);
188
+ if (!fs.existsSync(full) || !fs.readFileSync(full, "utf8").trim()) missing.push(`prompts/${role}.md`);
172
189
  }
173
190
  return { promptsDir, missing };
174
191
  }
@@ -11,6 +11,7 @@ import path from "node:path";
11
11
  import process from "node:process";
12
12
  import { readAdapter, resolveLoopDocumentPathsBySlug } from "./oris-loop-paths.mjs";
13
13
  import {
14
+ activeRoles,
14
15
  assessPromptFiles,
15
16
  improveMode,
16
17
  normalizeModels,
@@ -52,7 +53,7 @@ function getRoot(options) {
52
53
  }
53
54
 
54
55
  const options = parseArgs(process.argv.slice(2));
55
- if (!options.loopSlug) fail("--loop <slug> is required. List loops with: oris-skills loop list");
56
+ if (!options.loopSlug) fail("--loop <slug> is required. List loops with: npx oris-skills loop list");
56
57
  const root = getRoot(options);
57
58
  const adapter = readAdapter(root);
58
59
  const docs = resolveLoopDocumentPathsBySlug(root, options.loopSlug, adapter);
@@ -65,7 +66,8 @@ try {
65
66
  fail(error.message);
66
67
  }
67
68
  const metadata = loopDocument.data;
68
- const prompts = assessPromptFiles(path.dirname(docs.loopFull));
69
+ const roles = activeRoles(metadata);
70
+ const prompts = assessPromptFiles(path.dirname(docs.loopFull), roles);
69
71
  const models = normalizeModels(metadata.models, adapter.models);
70
72
 
71
73
  const perLoopOrchestrator = path.join(docs.promptsFull, "orchestrator.md");
@@ -84,6 +86,7 @@ const followup = substitutePlaceholders(template, {
84
86
  loopPath: docs.loopRelative,
85
87
  contextPath: docs.contextRelative,
86
88
  promptsDir: docs.promptsRelative,
89
+ roles: roles.join(", "),
87
90
  executorModel: models.executor,
88
91
  verifierModel: models.verifier,
89
92
  doctorModel: models.doctor,
@@ -95,6 +98,7 @@ const followup = substitutePlaceholders(template, {
95
98
  console.log(`Oris loop dry run: ${metadata.name}`);
96
99
  console.log(`Definition: ${docs.loopRelative} (schemaVersion ${metadata.schemaVersion}, approved: ${metadata.approved})`);
97
100
  console.log(`Prompts: ${docs.promptsRelative} ${prompts.missing.length === 0 ? "(complete)" : `(MISSING: ${prompts.missing.join(", ")})`}`);
101
+ console.log(`Roles: ${roles.join(", ")}`);
98
102
  console.log(`Models: executor=${models.executor} verifier=${models.verifier} doctor=${models.doctor} debriefer=${models.debriefer}`);
99
103
  console.log(`Improve mode: ${improveMode(metadata)}`);
100
104
  console.log(`Limits: ${metadata.limits.maxIterations} iterations, ${metadata.limits.maxNoProgress} no-progress, ${metadata.limits.maxMinutes ?? 240} minutes`);
@@ -107,4 +111,4 @@ if (prompts.missing.length > 0) {
107
111
  console.log("NOT runnable yet — create the missing prompt files first (templates: skills/oris-loop/templates/).");
108
112
  process.exit(1);
109
113
  }
110
- console.log(`Runnable. Start it with: oris-skills loop chat --action start --loop ${metadata.name}`);
114
+ console.log(`Runnable. Start it with: npx oris-skills loop chat --action start --loop ${metadata.name}`);
@@ -8,7 +8,7 @@ import {
8
8
  writeStopHookWrapper,
9
9
  } from "./oris-loop-bootstrap.mjs";
10
10
  import { defaultLoopDocumentPaths, readAdapter } from "./oris-loop-paths.mjs";
11
- import { ORIS_FLOW_ADAPTER } from "../flow/oris-flow-layout.mjs";
11
+ import { ORIS_FLOW_ADAPTER, ORIS_FLOW_TASKS_ROOT } from "../flow/oris-flow-layout.mjs";
12
12
 
13
13
  export function writeMinimalAdapter(root) {
14
14
  const adapterPath = path.join(root, ORIS_FLOW_ADAPTER);
@@ -22,6 +22,7 @@ export function writeMinimalAdapter(root) {
22
22
  export function loopDocumentSource({
23
23
  program = "oris-loop-smoke",
24
24
  phases = ["work"],
25
+ roles = ["executor", "verifier"],
25
26
  improve = "propose",
26
27
  models = {},
27
28
  goal = "Run one deterministic smoke action, verify it, and record evidence.",
@@ -37,6 +38,7 @@ kind: oris-loop
37
38
  name: ${program}
38
39
  approved: true
39
40
  phases: [${phases.join(", ")}]
41
+ roles: [${roles.join(", ")}]
40
42
  limits:
41
43
  maxIterations: 25
42
44
  maxNoProgress: 2
@@ -65,20 +67,17 @@ export function rolePromptFixtures() {
65
67
  return {
66
68
  "executor.md": `# Executor prompt
67
69
 
70
+ ORIS LOOP ACTIVE — approved smoke pass.
68
71
  Perform exactly one bounded smoke action inside allowed paths.
69
72
  Runtime: iteration {{iteration}}, phase {{phase}}, target {{currentTarget}}.
70
- RETURN compact JSON: { "role": "executor", "state": "claimed|blocked", "summary": "", "changedFiles": [], "claim": "" }
73
+ RETURN compact JSON (\`\`\`json fenced): { "role": "executor", "state": "claimed|blocked", "summary": "", "changedFiles": [], "claim": "" }
71
74
  `,
72
75
  "verifier.md": `# Verifier prompt
73
76
 
74
77
  Verify independently: \`node --version\` exits 0. NEVER trust the executor without running the check.
75
78
  Runtime: iteration {{iteration}}; executor claim supplied by orchestrator.
76
- RETURN compact JSON: { "role": "verifier", "state": "pass|fail|blocked", "summary": "", "evidence": "" }
77
- `,
78
- "doctor.md": `# Doctor prompt
79
-
80
- Diagnose from evidence. Stop rules: success when the smoke check passes; blocked on missing tools.
81
- RETURN compact JSON: { "role": "doctor", "decision": "continue|complete|stop", "diagnosis": "" }
79
+ goalMet = true when the smoke check passed (the loop goal is one verified pass).
80
+ RETURN compact JSON (\`\`\`json fenced): { "role": "verifier", "state": "pass|fail|blocked|inconclusive", "goalMet": false, "summary": "", "evidence": "" }
82
81
  `,
83
82
  };
84
83
  }
@@ -88,16 +87,18 @@ export function writeLoopTask(root, {
88
87
  program = "oris-loop-smoke",
89
88
  phases = ["work"],
90
89
  phase = phases[0],
90
+ roles = ["executor", "verifier"],
91
91
  improve = "propose",
92
92
  models = {},
93
93
  } = {}) {
94
- const taskPath = taskSlug;
94
+ const taskPath = `${ORIS_FLOW_TASKS_ROOT}/${taskSlug}`;
95
+ fs.mkdirSync(path.join(root, ...taskPath.split("/")), { recursive: true });
95
96
  const adapter = readAdapter(root);
96
97
  const loopDocs = defaultLoopDocumentPaths(root, taskPath, adapter);
97
98
  const loopPath = loopDocs.loopFull;
98
99
  const contextPath = loopDocs.contextFull;
99
100
  fs.mkdirSync(path.dirname(loopPath), { recursive: true });
100
- fs.writeFileSync(loopPath, loopDocumentSource({ program, phases, improve, models }), "utf8");
101
+ fs.writeFileSync(loopPath, loopDocumentSource({ program, phases, roles, improve, models }), "utf8");
101
102
  fs.mkdirSync(loopDocs.promptsFull, { recursive: true });
102
103
  for (const [name, content] of Object.entries(rolePromptFixtures())) {
103
104
  fs.writeFileSync(path.join(loopDocs.promptsFull, name), content, "utf8");
@@ -111,7 +112,7 @@ Current phase: ${phase}
111
112
  Current phase index: 0
112
113
  Current iteration: 0
113
114
  Phase iteration: 0
114
- Repair cycles: 0
115
+ No-progress streak: 0
115
116
  Updated: ${new Date().toISOString()}
116
117
 
117
118
  ## Objective
@@ -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
  }