oris-skills 2.0.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/agents/oris-loop-debriefer.md +29 -0
- package/agents/oris-loop-doctor.md +32 -0
- package/agents/oris-loop-executor.md +32 -0
- package/agents/oris-loop-verifier.md +31 -0
- package/docs/architecture.md +26 -0
- package/docs/distribution.md +38 -0
- package/docs/maintainer-guide.md +32 -0
- package/docs/user-guide.md +42 -0
- package/package.json +49 -0
- package/references/clean-code-checklist.md +107 -0
- package/references/conventions.md +39 -0
- package/references/doc-policy.md +24 -0
- package/references/loop-adapter.schema.json +126 -0
- package/references/loop-contract.md +119 -0
- package/references/loop.schema.json +143 -0
- package/references/questions.md +38 -0
- package/references/repo-map.md +53 -0
- package/references/repo-map.schema.json +198 -0
- package/references/settings.md +35 -0
- package/references/settings.schema.json +75 -0
- package/scripts/flow/oris-flow-clean-runtime.mjs +182 -0
- package/scripts/flow/oris-flow-layout.mjs +53 -0
- package/scripts/flow/oris-flow-scan.mjs +350 -0
- package/scripts/flow/oris-flow-version-control.mjs +42 -0
- package/scripts/flow/oris-gitignore.mjs +79 -0
- package/scripts/install/generate-agent-adapters.mjs +74 -0
- package/scripts/install/install-user-skills.mjs +271 -0
- package/scripts/install/uninstall-user-skills.mjs +163 -0
- package/scripts/loop/oris-loop-bootstrap.mjs +383 -0
- package/scripts/loop/oris-loop-bundle.mjs +22 -0
- package/scripts/loop/oris-loop-chat.mjs +408 -0
- package/scripts/loop/oris-loop-demo.mjs +180 -0
- package/scripts/loop/oris-loop-document.mjs +179 -0
- package/scripts/loop/oris-loop-dry-run.mjs +110 -0
- package/scripts/loop/oris-loop-fixtures.mjs +148 -0
- package/scripts/loop/oris-loop-list.mjs +81 -0
- package/scripts/loop/oris-loop-paths.mjs +232 -0
- package/scripts/loop/oris-loop-run.mjs +241 -0
- package/scripts/loop/oris-loop-stop.mjs +319 -0
- package/scripts/loop/oris-loop-templates.mjs +77 -0
- package/scripts/loop/oris-loop-verify.mjs +206 -0
- package/scripts/oris-skills.mjs +116 -0
- package/scripts/package-oris-skills.mjs +53 -0
- package/scripts/tests/run-all-tests.mjs +38 -0
- package/scripts/tests/test-agent-adapters.mjs +19 -0
- package/scripts/tests/test-oris-1-0-cleanliness.mjs +58 -0
- package/scripts/tests/test-oris-flow-clean-runtime.mjs +75 -0
- package/scripts/tests/test-oris-flow-scan.mjs +49 -0
- package/scripts/tests/test-oris-gitignore.mjs +35 -0
- package/scripts/tests/test-oris-loop-bootstrap.mjs +94 -0
- package/scripts/tests/test-oris-loop-document.mjs +112 -0
- package/scripts/tests/test-oris-loop-list.mjs +74 -0
- package/scripts/tests/test-oris-loop-run.mjs +45 -0
- package/scripts/tests/test-oris-loop-smoke.mjs +130 -0
- package/scripts/tests/test-oris-loop-stop.mjs +371 -0
- package/scripts/tests/test-routing-lifecycle.mjs +181 -0
- package/scripts/tests/test-schemas.mjs +55 -0
- package/skills/oris-flow/SKILL.md +55 -0
- package/skills/oris-flow/agents/openai.yaml +4 -0
- package/skills/oris-flow-criteria/SKILL.md +49 -0
- package/skills/oris-flow-discover/SKILL.md +58 -0
- package/skills/oris-flow-docs/SKILL.md +31 -0
- package/skills/oris-flow-fix/SKILL.md +42 -0
- package/skills/oris-flow-implement/SKILL.md +43 -0
- package/skills/oris-flow-plan/SKILL.md +51 -0
- package/skills/oris-flow-setup/SKILL.md +49 -0
- package/skills/oris-help/SKILL.md +30 -0
- package/skills/oris-help/agents/openai.yaml +4 -0
- package/skills/oris-loop/SKILL.md +66 -0
- package/skills/oris-loop/agents/openai.yaml +4 -0
- package/skills/oris-loop/references/craft.md +48 -0
- package/skills/oris-loop/references/improve.md +23 -0
- package/skills/oris-loop/references/run.md +30 -0
- package/skills/oris-loop/templates/debriefer.md +18 -0
- package/skills/oris-loop/templates/doctor.md +20 -0
- package/skills/oris-loop/templates/executor.md +19 -0
- package/skills/oris-loop/templates/orchestrator.md +33 -0
- package/skills/oris-loop/templates/verifier.md +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
ORIS_FLOW_ADAPTER,
|
|
5
|
+
ORIS_FLOW_LOOPS_ROOT,
|
|
6
|
+
ORIS_FLOW_RUNTIME,
|
|
7
|
+
safeSlug,
|
|
8
|
+
toPosixPath,
|
|
9
|
+
} from "../flow/oris-flow-layout.mjs";
|
|
10
|
+
import { parseFrontMatter, validateLoopMetadata } from "./oris-loop-document.mjs";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RUNTIME_RELATIVE = ORIS_FLOW_RUNTIME;
|
|
13
|
+
const DEFAULT_LOOPS_RELATIVE = ORIS_FLOW_LOOPS_ROOT;
|
|
14
|
+
|
|
15
|
+
export function safeTaskSlug(taskPath) {
|
|
16
|
+
return safeSlug(taskPath, "loop");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runtimeRelativePath(adapter) {
|
|
20
|
+
const value = adapter?.paths?.runtime ?? DEFAULT_RUNTIME_RELATIVE;
|
|
21
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value) || value.split(/[\\/]/).includes("..")) {
|
|
22
|
+
throw new Error("Adapter paths.runtime must be a non-empty repository-relative path without '..'.");
|
|
23
|
+
}
|
|
24
|
+
return value.replace(/\\/g, "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loopsRelativePath(adapter) {
|
|
28
|
+
const value = adapter?.paths?.loops ?? DEFAULT_LOOPS_RELATIVE;
|
|
29
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value) || value.split(/[\\/]/).includes("..")) {
|
|
30
|
+
throw new Error("Adapter paths.loops must be a non-empty repository-relative path without '..'.");
|
|
31
|
+
}
|
|
32
|
+
return value.replace(/\\/g, "/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loopDocsDirectory(loopsRelative, taskSlug) {
|
|
36
|
+
return path.join(loopsRelative, taskSlug).replace(/\\/g, "/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveExplicitOrDefault(root, explicit, defaultRelative) {
|
|
40
|
+
if (explicit) return path.resolve(root, explicit);
|
|
41
|
+
return path.resolve(root, defaultRelative);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve loop.md and context.md paths under `.oris-flow/loops/{slug}/`.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveLoopDocumentPaths(root, options, adapter = null) {
|
|
48
|
+
const taskPath = String(options.taskPath ?? "").replace(/\\/g, "/");
|
|
49
|
+
if (!taskPath) throw new Error("--task is required.");
|
|
50
|
+
const slug = safeTaskSlug(taskPath);
|
|
51
|
+
const resolvedAdapter = adapter ?? readAdapter(root, options.adapterPath);
|
|
52
|
+
const runtimeRelative = runtimeRelativePath(resolvedAdapter);
|
|
53
|
+
const loopsRelative = loopsRelativePath(resolvedAdapter);
|
|
54
|
+
const loopDir = loopDocsDirectory(loopsRelative, slug);
|
|
55
|
+
const defaultLoopRelative = path.join(loopDir, "loop.md").replace(/\\/g, "/");
|
|
56
|
+
const defaultContextRelative = path.join(loopDir, "context.md").replace(/\\/g, "/");
|
|
57
|
+
|
|
58
|
+
const loopFull = options.loopPath
|
|
59
|
+
? resolveExplicitOrDefault(root, options.loopPath, defaultLoopRelative)
|
|
60
|
+
: resolveExplicitOrDefault(root, "", defaultLoopRelative);
|
|
61
|
+
const contextFull = options.contextPath
|
|
62
|
+
? resolveExplicitOrDefault(root, options.contextPath, defaultContextRelative)
|
|
63
|
+
: resolveExplicitOrDefault(root, "", defaultContextRelative);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
taskPath,
|
|
67
|
+
taskSlug: slug,
|
|
68
|
+
runtimeRelative,
|
|
69
|
+
loopsRelative,
|
|
70
|
+
loopDir,
|
|
71
|
+
runtimeDir: runtimeRelative,
|
|
72
|
+
loopFull,
|
|
73
|
+
contextFull,
|
|
74
|
+
promptsFull: path.join(path.dirname(loopFull), "prompts"),
|
|
75
|
+
loopRelative: path.relative(root, loopFull).replace(/\\/g, "/"),
|
|
76
|
+
contextRelative: path.relative(root, contextFull).replace(/\\/g, "/"),
|
|
77
|
+
promptsRelative: path.join(path.dirname(path.relative(root, loopFull)), "prompts").replace(/\\/g, "/"),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveLoopDocumentPathsBySlug(root, loopSlug, adapter = null) {
|
|
82
|
+
const slug = String(loopSlug ?? "").trim();
|
|
83
|
+
if (!slug || safeSlug(slug) !== slug) {
|
|
84
|
+
throw new Error("--loop must be an existing loop slug, not a path.");
|
|
85
|
+
}
|
|
86
|
+
const resolvedAdapter = adapter ?? readAdapter(root);
|
|
87
|
+
const runtimeRelative = runtimeRelativePath(resolvedAdapter);
|
|
88
|
+
const loopsRelative = loopsRelativePath(resolvedAdapter);
|
|
89
|
+
const loopDir = loopDocsDirectory(loopsRelative, slug);
|
|
90
|
+
const loopFull = path.resolve(root, loopDir, "loop.md");
|
|
91
|
+
const contextFull = path.resolve(root, loopDir, "context.md");
|
|
92
|
+
return {
|
|
93
|
+
taskPath: slug,
|
|
94
|
+
taskSlug: slug,
|
|
95
|
+
runtimeRelative,
|
|
96
|
+
loopsRelative,
|
|
97
|
+
loopDir,
|
|
98
|
+
runtimeDir: runtimeRelative,
|
|
99
|
+
loopFull,
|
|
100
|
+
contextFull,
|
|
101
|
+
promptsFull: path.resolve(root, loopDir, "prompts"),
|
|
102
|
+
loopRelative: path.relative(root, loopFull).replace(/\\/g, "/"),
|
|
103
|
+
contextRelative: path.relative(root, contextFull).replace(/\\/g, "/"),
|
|
104
|
+
promptsRelative: path.join(loopDir, "prompts").replace(/\\/g, "/"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function defaultLoopDocumentPaths(root, taskPath, adapter = null) {
|
|
109
|
+
const slug = safeTaskSlug(taskPath);
|
|
110
|
+
const resolvedAdapter = adapter ?? readAdapter(root);
|
|
111
|
+
const runtimeRelative = runtimeRelativePath(resolvedAdapter);
|
|
112
|
+
const loopsRelative = loopsRelativePath(resolvedAdapter);
|
|
113
|
+
const loopDir = loopDocsDirectory(loopsRelative, slug);
|
|
114
|
+
return {
|
|
115
|
+
taskPath: toPosixPath(taskPath),
|
|
116
|
+
taskSlug: slug,
|
|
117
|
+
runtimeRelative,
|
|
118
|
+
loopsRelative,
|
|
119
|
+
loopDir,
|
|
120
|
+
runtimeDir: runtimeRelative,
|
|
121
|
+
loopFull: path.resolve(root, loopDir, "loop.md"),
|
|
122
|
+
contextFull: path.resolve(root, loopDir, "context.md"),
|
|
123
|
+
promptsFull: path.resolve(root, loopDir, "prompts"),
|
|
124
|
+
loopRelative: path.join(loopDir, "loop.md").replace(/\\/g, "/"),
|
|
125
|
+
contextRelative: path.join(loopDir, "context.md").replace(/\\/g, "/"),
|
|
126
|
+
promptsRelative: path.join(loopDir, "prompts").replace(/\\/g, "/"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readAdapter(root, adapterPath = ORIS_FLOW_ADAPTER) {
|
|
131
|
+
const adapterFull = path.resolve(root, adapterPath);
|
|
132
|
+
if (!fs.existsSync(adapterFull)) return { paths: { runtime: DEFAULT_RUNTIME_RELATIVE, loops: DEFAULT_LOOPS_RELATIVE } };
|
|
133
|
+
return JSON.parse(fs.readFileSync(adapterFull, "utf8"));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isTaskPathUnderAdapterRoots(root, taskPath, adapter) {
|
|
137
|
+
const taskFull = path.resolve(root, taskPath);
|
|
138
|
+
const taskRoots = adapter?.paths?.taskRoots ?? [];
|
|
139
|
+
if (!Array.isArray(taskRoots) || taskRoots.length === 0) return true;
|
|
140
|
+
return taskRoots.some((value) => {
|
|
141
|
+
const parent = path.resolve(root, value);
|
|
142
|
+
const relative = path.relative(parent, taskFull);
|
|
143
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readMarkdownField(text, fieldName) {
|
|
148
|
+
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
const match = text.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"));
|
|
150
|
+
return match ? match[1].trim() : "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readMarkdownSection(text, heading) {
|
|
154
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
155
|
+
const match = text.match(new RegExp(`^## ${escaped}\\s*\\n+([\\s\\S]*?)(?:\\n## |\\n*$)`, "m"));
|
|
156
|
+
if (!match) return "";
|
|
157
|
+
return match[1]
|
|
158
|
+
.split(/\r?\n/)
|
|
159
|
+
.map((line) => line.trim())
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.slice(0, 3)
|
|
162
|
+
.join(" ");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readLoopMetadata(loopText) {
|
|
166
|
+
try {
|
|
167
|
+
const { data } = parseFrontMatter(loopText);
|
|
168
|
+
validateLoopMetadata(data);
|
|
169
|
+
return data;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function summarizeLoopDocuments(root, entry) {
|
|
176
|
+
const loopText = fs.existsSync(entry.loopFull) ? fs.readFileSync(entry.loopFull, "utf8") : "";
|
|
177
|
+
const contextText = fs.existsSync(entry.contextFull) ? fs.readFileSync(entry.contextFull, "utf8") : "";
|
|
178
|
+
const objective = readMarkdownSection(contextText, "Objective");
|
|
179
|
+
const nextAction = readMarkdownSection(contextText, "Next action");
|
|
180
|
+
const summary = objective || nextAction || loopText.split(/\r?\n/).find((line) => line.trim() && !line.startsWith("#"))?.trim() || "";
|
|
181
|
+
const metadata = readLoopMetadata(loopText);
|
|
182
|
+
return {
|
|
183
|
+
...entry,
|
|
184
|
+
program: metadata?.name ?? "",
|
|
185
|
+
runtime: metadata?.runtime ?? "",
|
|
186
|
+
phases: metadata?.phases ?? [],
|
|
187
|
+
phase: readMarkdownField(contextText, "Current phase") || "",
|
|
188
|
+
run: readMarkdownField(contextText, "Run") || "inactive",
|
|
189
|
+
state: readMarkdownField(contextText, "State") || "",
|
|
190
|
+
updated: readMarkdownField(contextText, "Updated") || "",
|
|
191
|
+
objective: summary,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Discover loop documents under `.oris-flow/loops/{slug}/`.
|
|
197
|
+
*/
|
|
198
|
+
export function discoverExistingLoops(root, adapter = null) {
|
|
199
|
+
const resolvedRoot = path.resolve(root);
|
|
200
|
+
const resolvedAdapter = adapter ?? readAdapter(resolvedRoot);
|
|
201
|
+
const runtimeRelative = runtimeRelativePath(resolvedAdapter);
|
|
202
|
+
const loopsRelative = loopsRelativePath(resolvedAdapter);
|
|
203
|
+
const loopsFull = path.resolve(resolvedRoot, loopsRelative);
|
|
204
|
+
const loops = [];
|
|
205
|
+
|
|
206
|
+
if (!fs.existsSync(loopsFull)) {
|
|
207
|
+
return loops;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const entry of fs.readdirSync(loopsFull, { withFileTypes: true })) {
|
|
211
|
+
if (!entry.isDirectory()) continue;
|
|
212
|
+
const slug = entry.name;
|
|
213
|
+
const loopFull = path.join(loopsFull, slug, "loop.md");
|
|
214
|
+
const contextFull = path.join(loopsFull, slug, "context.md");
|
|
215
|
+
if (!fs.existsSync(loopFull) || !fs.existsSync(contextFull)) continue;
|
|
216
|
+
loops.push(summarizeLoopDocuments(resolvedRoot, {
|
|
217
|
+
root: resolvedRoot,
|
|
218
|
+
slug,
|
|
219
|
+
taskPath: slug,
|
|
220
|
+
runtimeRelative,
|
|
221
|
+
loopsRelative,
|
|
222
|
+
loopDir: loopDocsDirectory(loopsRelative, slug),
|
|
223
|
+
runtimeDir: runtimeRelative,
|
|
224
|
+
loopFull,
|
|
225
|
+
contextFull,
|
|
226
|
+
loopRelative: path.relative(resolvedRoot, loopFull).replace(/\\/g, "/"),
|
|
227
|
+
contextRelative: path.relative(resolvedRoot, contextFull).replace(/\\/g, "/"),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return loops.sort((left, right) => left.slug.localeCompare(right.slug));
|
|
232
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `oris-skills loop run --loop <slug> --agent codex|claude` — headless runner.
|
|
5
|
+
*
|
|
6
|
+
* Platforms without an injectable stop hook (Codex today) still get true
|
|
7
|
+
* role isolation: every role runs as a separate CLI invocation with its own
|
|
8
|
+
* prompt file and its own model. The same runner also works with Claude Code
|
|
9
|
+
* (`claude -p`) for CI-style unattended loops.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import childProcess from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import process from "node:process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { readAdapter, resolveLoopDocumentPathsBySlug } from "./oris-loop-paths.mjs";
|
|
18
|
+
import {
|
|
19
|
+
assessPromptFiles,
|
|
20
|
+
improveMode,
|
|
21
|
+
normalizeModels,
|
|
22
|
+
validateLoopDocumentText,
|
|
23
|
+
} from "./oris-loop-document.mjs";
|
|
24
|
+
import { substitutePlaceholders } from "./oris-loop-templates.mjs";
|
|
25
|
+
import { ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
|
|
26
|
+
|
|
27
|
+
const ROLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
function fail(message) {
|
|
30
|
+
console.error(`[oris-skills] ERROR ${message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const options = { repositoryRoot: "", loopSlug: "", agent: "codex", maxPasses: 0 };
|
|
36
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
37
|
+
const raw = argv[i];
|
|
38
|
+
const value = (flag) => {
|
|
39
|
+
if (raw.includes("=")) return raw.slice(raw.indexOf("=") + 1);
|
|
40
|
+
if (i + 1 >= argv.length) fail(`Missing value for ${flag}.`);
|
|
41
|
+
i += 1;
|
|
42
|
+
return argv[i];
|
|
43
|
+
};
|
|
44
|
+
if (raw === "--loop" || raw.startsWith("--loop=")) options.loopSlug = value("--loop");
|
|
45
|
+
else if (raw === "--agent" || raw.startsWith("--agent=")) options.agent = value("--agent");
|
|
46
|
+
else if (raw === "--max-passes" || raw.startsWith("--max-passes=")) options.maxPasses = Number.parseInt(value("--max-passes"), 10);
|
|
47
|
+
else if (raw === "--repository-root" || raw.startsWith("--repository-root=")) options.repositoryRoot = value("--repository-root");
|
|
48
|
+
else fail(`Unknown argument: ${raw}`);
|
|
49
|
+
}
|
|
50
|
+
options.agent = options.agent.toLowerCase();
|
|
51
|
+
if (!["codex", "claude"].includes(options.agent)) fail("--agent must be codex or claude.");
|
|
52
|
+
if (!options.loopSlug) fail("--loop <slug> is required.");
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getRoot(options) {
|
|
57
|
+
if (options.repositoryRoot) return path.resolve(options.repositoryRoot);
|
|
58
|
+
const result = childProcess.spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
59
|
+
cwd: process.cwd(),
|
|
60
|
+
shell: process.platform === "win32",
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
});
|
|
63
|
+
if (result.status !== 0 || !result.stdout.trim()) fail("Run inside a Git worktree or pass --repository-root.");
|
|
64
|
+
return path.resolve(result.stdout.trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build the CLI invocation for one role. Exported for tests. */
|
|
68
|
+
export function buildRoleInvocation(agent, { prompt, model, root }) {
|
|
69
|
+
if (agent === "claude") {
|
|
70
|
+
const args = ["-p", prompt, "--permission-mode", "acceptEdits"];
|
|
71
|
+
if (model && model !== "inherit") args.push("--model", model);
|
|
72
|
+
return { command: "claude", args, cwd: root };
|
|
73
|
+
}
|
|
74
|
+
const args = ["exec", "--sandbox", "workspace-write", "--cd", root];
|
|
75
|
+
if (model && model !== "inherit") args.push("-m", model);
|
|
76
|
+
args.push(prompt);
|
|
77
|
+
return { command: "codex", args, cwd: root };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runRole(agent, role, prompt, model, root, evidenceDir, pass) {
|
|
81
|
+
const invocation = buildRoleInvocation(agent, { prompt, model, root });
|
|
82
|
+
process.stdout.write(` ${role} (${agent}${model && model !== "inherit" ? `, ${model}` : ""})... `);
|
|
83
|
+
const result = childProcess.spawnSync(invocation.command, invocation.args, {
|
|
84
|
+
cwd: invocation.cwd,
|
|
85
|
+
shell: process.platform === "win32",
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
timeout: ROLE_TIMEOUT_MS,
|
|
88
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
89
|
+
});
|
|
90
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
91
|
+
const evidencePath = path.join(evidenceDir, `pass-${pass}-${role}.log`);
|
|
92
|
+
fs.writeFileSync(evidencePath, output || "(no output)", "utf8");
|
|
93
|
+
if (result.error) {
|
|
94
|
+
console.log("FAILED");
|
|
95
|
+
fail(`${invocation.command} could not run (${result.error.message}). Is the ${agent} CLI installed and on PATH?`);
|
|
96
|
+
}
|
|
97
|
+
console.log(result.status === 0 ? "done" : `exit ${result.status}`);
|
|
98
|
+
return { status: result.status ?? 1, output, evidencePath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Pull the last JSON object out of an agent's free-form output. */
|
|
102
|
+
export function extractJson(output) {
|
|
103
|
+
const text = String(output ?? "");
|
|
104
|
+
for (let start = text.lastIndexOf("{"); start !== -1; start = start === 0 ? -1 : text.lastIndexOf("{", start - 1)) {
|
|
105
|
+
for (let end = text.indexOf("}", start); end !== -1; end = text.indexOf("}", end + 1)) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
108
|
+
} catch {
|
|
109
|
+
// widen to the next closing brace
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function doctorDecisionToState(decision) {
|
|
117
|
+
const value = String(decision ?? "").toLowerCase();
|
|
118
|
+
if (value === "complete") return "complete";
|
|
119
|
+
if (value === "advance") return "advance";
|
|
120
|
+
if (value === "continue" || value === "retry") return "continue";
|
|
121
|
+
return "blocked";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function chatAction(root, args) {
|
|
125
|
+
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "oris-loop-chat.mjs");
|
|
126
|
+
const result = childProcess.spawnSync(process.execPath, [script, "--repository-root", root, ...args], {
|
|
127
|
+
cwd: root,
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
});
|
|
130
|
+
if (result.status !== 0) fail(`loop chat ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
131
|
+
return result.stdout;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readContextSection(contextText, heading) {
|
|
135
|
+
const match = contextText.match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n## |$)`, "m"));
|
|
136
|
+
return match ? match[1].trim() : "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function rolePrompt(promptsDir, name, values, extra) {
|
|
140
|
+
const text = fs.readFileSync(path.join(promptsDir, name), "utf8");
|
|
141
|
+
const filled = substitutePlaceholders(text, values);
|
|
142
|
+
return extra ? `${filled}\n\n${extra}` : filled;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function main() {
|
|
146
|
+
const options = parseArgs(process.argv.slice(2));
|
|
147
|
+
const root = getRoot(options);
|
|
148
|
+
const adapter = readAdapter(root);
|
|
149
|
+
const docs = resolveLoopDocumentPathsBySlug(root, options.loopSlug, adapter);
|
|
150
|
+
if (!fs.existsSync(docs.loopFull)) fail(`Loop not found: ${docs.loopRelative}`);
|
|
151
|
+
|
|
152
|
+
let loopDocument;
|
|
153
|
+
try {
|
|
154
|
+
loopDocument = validateLoopDocumentText(fs.readFileSync(docs.loopFull, "utf8"), docs.loopRelative);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
fail(error.message);
|
|
157
|
+
}
|
|
158
|
+
const metadata = loopDocument.data;
|
|
159
|
+
const prompts = assessPromptFiles(path.dirname(docs.loopFull));
|
|
160
|
+
if (prompts.missing.length > 0) fail(`Loop prompt files are missing: ${prompts.missing.join(", ")}`);
|
|
161
|
+
const models = normalizeModels(metadata.models, adapter.models);
|
|
162
|
+
const mode = improveMode(metadata);
|
|
163
|
+
const maxPasses = options.maxPasses > 0 ? Math.min(options.maxPasses, metadata.limits.maxIterations) : metadata.limits.maxIterations;
|
|
164
|
+
const evidenceDir = path.join(root, ORIS_FLOW_RUNTIME, "evidence");
|
|
165
|
+
const receiptsDir = path.join(path.dirname(docs.loopFull), "receipts");
|
|
166
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
167
|
+
fs.mkdirSync(receiptsDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
console.log(`Oris loop headless run: ${metadata.name} (agent: ${options.agent}, improve: ${mode})`);
|
|
170
|
+
chatAction(root, ["--action", "start", "--loop", options.loopSlug, "--runner", "headless"]);
|
|
171
|
+
|
|
172
|
+
let state = "continue";
|
|
173
|
+
for (let pass = 1; pass <= maxPasses && (state === "continue" || state === "advance"); pass += 1) {
|
|
174
|
+
console.log(`Pass ${pass}/${maxPasses}:`);
|
|
175
|
+
const contextText = fs.readFileSync(docs.contextFull, "utf8");
|
|
176
|
+
const runtimeValues = {
|
|
177
|
+
iteration: pass,
|
|
178
|
+
phase: metadata.phases[0],
|
|
179
|
+
currentTarget: readContextSection(contextText, "Next action") || "(see context)",
|
|
180
|
+
};
|
|
181
|
+
const contextBlock = `CURRENT CONTEXT (${docs.contextRelative}):\n${contextText}`;
|
|
182
|
+
|
|
183
|
+
const executor = runRole(options.agent, "executor",
|
|
184
|
+
rolePrompt(prompts.promptsDir, "executor.md", runtimeValues, contextBlock),
|
|
185
|
+
models.executor, root, evidenceDir, pass);
|
|
186
|
+
const executorResult = extractJson(executor.output);
|
|
187
|
+
|
|
188
|
+
const verifier = runRole(options.agent, "verifier",
|
|
189
|
+
rolePrompt(prompts.promptsDir, "verifier.md", runtimeValues,
|
|
190
|
+
`EXECUTOR CLAIM:\n${JSON.stringify(executorResult ?? { raw: "see evidence" }, null, 2)}`),
|
|
191
|
+
models.verifier, root, evidenceDir, pass);
|
|
192
|
+
const verifierResult = extractJson(verifier.output);
|
|
193
|
+
|
|
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);
|
|
200
|
+
|
|
201
|
+
if (mode === "auto" && fs.existsSync(path.join(prompts.promptsDir, "debriefer.md"))) {
|
|
202
|
+
runRole(options.agent, "debriefer",
|
|
203
|
+
rolePrompt(prompts.promptsDir, "debriefer.md", { ...runtimeValues, IMPROVE_MODE: mode },
|
|
204
|
+
`DOCTOR VERDICT:\n${JSON.stringify(doctorResult, null, 2)}\nPROMPTS DIR: ${docs.promptsRelative}`),
|
|
205
|
+
models.debriefer, root, evidenceDir, pass);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const receiptPath = path.join(receiptsDir, `pass-${String(pass).padStart(3, "0")}.md`);
|
|
209
|
+
fs.writeFileSync(receiptPath, `# Oris Loop Receipt
|
|
210
|
+
|
|
211
|
+
Loop: ${metadata.name}
|
|
212
|
+
Pass: ${pass}
|
|
213
|
+
Runner: headless (${options.agent})
|
|
214
|
+
Result: ${state}
|
|
215
|
+
|
|
216
|
+
Executor: ${executorResult?.summary ?? executorResult?.claim ?? "see evidence"}
|
|
217
|
+
Verifier: ${verifierResult?.state ?? "unknown"} — ${verifierResult?.summary ?? "see evidence"}
|
|
218
|
+
Doctor: ${doctorResult?.decision ?? "unknown"} — ${doctorResult?.diagnosis ?? "see evidence"}
|
|
219
|
+
|
|
220
|
+
Evidence:
|
|
221
|
+
- ${path.relative(root, executor.evidencePath).replace(/\\/g, "/")}
|
|
222
|
+
- ${path.relative(root, verifier.evidencePath).replace(/\\/g, "/")}
|
|
223
|
+
- ${path.relative(root, doctor.evidencePath).replace(/\\/g, "/")}
|
|
224
|
+
`, "utf8");
|
|
225
|
+
|
|
226
|
+
chatAction(root, ["--action", "set-state", "--state", state === "advance" ? "advance" : state]);
|
|
227
|
+
console.log(` state: ${state} | receipt: ${path.relative(root, receiptPath).replace(/\\/g, "/")}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (state === "continue" || state === "advance") {
|
|
231
|
+
chatAction(root, ["--action", "set-state", "--state", "blocked"]);
|
|
232
|
+
console.log("Pass budget exhausted before completion — state set to blocked.");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
console.log(`Headless run finished: ${state}`);
|
|
236
|
+
process.exit(state === "complete" ? 0 : 1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
240
|
+
main();
|
|
241
|
+
}
|