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
|
@@ -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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
166
|
-
export function
|
|
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
|
|
170
|
-
const full = path.join(promptsDir,
|
|
171
|
-
if (!fs.existsSync(full) || !fs.readFileSync(full, "utf8").trim()) missing.push(`prompts/${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|