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.
- package/.cursor-plugin/plugin.json +6 -1
- package/CHANGELOG.md +38 -0
- package/README.md +11 -10
- package/agents/oris-loop-doctor.md +1 -1
- package/agents/oris-loop-verifier.md +3 -0
- package/docs/architecture.md +4 -3
- package/docs/distribution.md +1 -1
- package/docs/maintainer-guide.md +2 -2
- package/docs/user-guide.md +9 -7
- package/package.json +2 -3
- package/references/clean-code-checklist.md +20 -107
- package/references/conventions.md +16 -2
- package/references/doc-policy.md +19 -3
- package/references/loop-contract.md +23 -16
- package/references/loop.schema.json +13 -0
- package/references/repo-map.md +3 -3
- package/references/repo-map.schema.json +37 -0
- package/scripts/flow/oris-flow-layout.mjs +1 -0
- package/scripts/flow/oris-flow-scan.mjs +195 -27
- package/scripts/install/generate-agent-adapters.mjs +19 -2
- package/scripts/install/install-user-skills.mjs +7 -1
- package/scripts/loop/oris-loop-bootstrap.mjs +7 -2
- package/scripts/loop/oris-loop-chat.mjs +32 -14
- package/scripts/loop/oris-loop-demo.mjs +11 -20
- package/scripts/loop/oris-loop-document.mjs +24 -7
- package/scripts/loop/oris-loop-dry-run.mjs +7 -3
- package/scripts/loop/oris-loop-fixtures.mjs +12 -11
- package/scripts/loop/oris-loop-run.mjs +80 -20
- package/scripts/loop/oris-loop-stop.mjs +48 -9
- package/scripts/loop/oris-loop-templates.mjs +10 -7
- package/scripts/loop/oris-loop-verify.mjs +1 -2
- package/scripts/oris-skills.mjs +2 -1
- package/scripts/tests/run-all-tests.mjs +0 -2
- package/scripts/tests/test-oris-flow-scan.mjs +66 -0
- package/scripts/tests/test-oris-loop-document.mjs +39 -3
- package/scripts/tests/test-oris-loop-run.mjs +28 -2
- package/scripts/tests/test-oris-loop-smoke.mjs +36 -3
- package/scripts/tests/test-oris-loop-stop.mjs +65 -4
- package/scripts/tests/test-routing-lifecycle.mjs +11 -9
- package/skills/oris-flow/SKILL.md +16 -3
- package/skills/oris-flow-architecture/SKILL.md +64 -0
- package/skills/oris-flow-change/SKILL.md +55 -0
- package/skills/oris-flow-criteria/SKILL.md +10 -4
- package/skills/oris-flow-discover/SKILL.md +10 -5
- package/skills/oris-flow-docs/SKILL.md +2 -2
- package/skills/oris-flow-fix/SKILL.md +1 -1
- package/skills/oris-flow-implement/SKILL.md +2 -2
- package/skills/oris-flow-merge/SKILL.md +45 -0
- package/skills/oris-flow-new/SKILL.md +58 -0
- package/skills/oris-flow-plan/SKILL.md +8 -4
- package/skills/oris-flow-setup/SKILL.md +54 -23
- package/skills/oris-flow-verify/SKILL.md +50 -0
- package/skills/oris-help/SKILL.md +4 -4
- package/skills/oris-loop/SKILL.md +12 -32
- package/skills/oris-loop/references/craft.md +10 -9
- package/skills/oris-loop/references/run.md +9 -7
- package/skills/oris-loop/templates/debriefer.md +1 -1
- package/skills/oris-loop/templates/doctor.md +9 -7
- package/skills/oris-loop/templates/executor.md +3 -2
- package/skills/oris-loop/templates/orchestrator.md +10 -7
- package/skills/oris-loop/templates/verifier.md +4 -2
- 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 (
|
|
151
|
-
|
|
152
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/scripts/oris-skills.mjs
CHANGED
|
@@ -92,7 +92,8 @@ if (command === "doctor") {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
if (command === "validate") {
|
|
95
|
-
|
|
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") {
|
|
@@ -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
|
|
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"
|
|
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"
|
|
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("
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
59
|
-
|
|
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,
|
|
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
|
-
|
|
58
|
-
assert.match(
|
|
59
|
-
assert.match(
|
|
60
|
-
assert.match(
|
|
61
|
-
assert.match(
|
|
62
|
-
assert.match(
|
|
63
|
-
assert.
|
|
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
|
-
|
|
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
|
|