oris-skills 2.2.3 → 3.0.2
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 +2 -3
- package/CHANGELOG.md +24 -6
- package/README.md +28 -60
- package/docs/architecture.md +15 -11
- package/docs/distribution.md +3 -3
- package/docs/maintainer-guide.md +4 -4
- package/docs/user-guide.md +12 -20
- package/package.json +3 -6
- package/references/clean-code-checklist.md +2 -2
- package/references/conventions.md +36 -16
- package/references/doc-policy.md +5 -5
- package/references/research.md +45 -0
- package/references/settings.md +11 -5
- package/references/settings.schema.json +10 -0
- package/scripts/flow/oris-flow-layout.mjs +0 -16
- package/scripts/flow/oris-flow-scan.mjs +32 -317
- package/scripts/flow/oris-gitignore.mjs +1 -5
- package/scripts/install/install-user-skills.mjs +3 -3
- package/scripts/install/uninstall-user-skills.mjs +2 -29
- package/scripts/oris-skills.mjs +0 -47
- package/scripts/tests/run-all-tests.mjs +1 -9
- package/scripts/tests/test-cleanliness.mjs +54 -0
- package/scripts/tests/test-oris-flow-scan.mjs +21 -91
- package/scripts/tests/test-routing-lifecycle.mjs +24 -54
- package/scripts/tests/test-schemas.mjs +17 -31
- package/scripts/tests/test-skill-style.mjs +5 -2
- package/skills/oris-flow/SKILL.md +21 -11
- package/skills/oris-flow/agents/openai.yaml +1 -1
- package/skills/oris-flow/references/architecture.md +6 -3
- package/skills/oris-flow/references/change.md +3 -0
- package/skills/oris-flow/references/commit.md +9 -8
- package/skills/oris-flow/references/criteria.md +9 -5
- package/skills/oris-flow/references/discover.md +2 -2
- package/skills/oris-flow/references/docs.md +5 -1
- package/skills/oris-flow/references/fix.md +10 -5
- package/skills/oris-flow/references/handoff.md +67 -0
- package/skills/oris-flow/references/help.md +3 -6
- package/skills/oris-flow/references/implement.md +7 -4
- package/skills/oris-flow/references/new.md +6 -6
- package/skills/oris-flow/references/plan.md +7 -2
- package/skills/oris-flow/references/pr.md +2 -2
- package/skills/oris-flow/references/setup.md +33 -39
- package/skills/oris-flow/references/triage.md +69 -0
- package/skills/oris-flow/references/verify.md +5 -5
- package/agents/oris-loop-debriefer.md +0 -30
- package/agents/oris-loop-doctor.md +0 -32
- package/agents/oris-loop-executor.md +0 -35
- package/agents/oris-loop-verifier.md +0 -35
- package/references/loop-adapter.schema.json +0 -93
- package/references/loop-contract.md +0 -126
- package/references/loop.schema.json +0 -156
- package/references/repo-map.md +0 -51
- package/references/repo-map.schema.json +0 -213
- package/scripts/flow/oris-flow-clean-runtime.mjs +0 -182
- package/scripts/install/generate-agent-adapters.mjs +0 -81
- package/scripts/loop/oris-loop-bootstrap.mjs +0 -383
- package/scripts/loop/oris-loop-bundle.mjs +0 -22
- package/scripts/loop/oris-loop-chat.mjs +0 -396
- package/scripts/loop/oris-loop-demo.mjs +0 -171
- package/scripts/loop/oris-loop-document.mjs +0 -196
- package/scripts/loop/oris-loop-dry-run.mjs +0 -114
- package/scripts/loop/oris-loop-fixtures.mjs +0 -149
- package/scripts/loop/oris-loop-list.mjs +0 -81
- package/scripts/loop/oris-loop-paths.mjs +0 -232
- package/scripts/loop/oris-loop-run.mjs +0 -301
- package/scripts/loop/oris-loop-stop.mjs +0 -358
- package/scripts/loop/oris-loop-templates.mjs +0 -80
- package/scripts/loop/oris-loop-verify.mjs +0 -205
- package/scripts/tests/test-agent-adapters.mjs +0 -70
- package/scripts/tests/test-oris-1-0-cleanliness.mjs +0 -58
- package/scripts/tests/test-oris-flow-clean-runtime.mjs +0 -75
- package/scripts/tests/test-oris-loop-bootstrap.mjs +0 -94
- package/scripts/tests/test-oris-loop-document.mjs +0 -148
- package/scripts/tests/test-oris-loop-list.mjs +0 -74
- package/scripts/tests/test-oris-loop-run.mjs +0 -71
- package/scripts/tests/test-oris-loop-smoke.mjs +0 -120
- package/scripts/tests/test-oris-loop-stop.mjs +0 -432
- package/skills/oris-flow/references/loop-craft.md +0 -59
- package/skills/oris-flow/references/loop-improve.md +0 -32
- package/skills/oris-flow/references/loop-run.md +0 -47
- package/skills/oris-flow/references/loop.md +0 -56
- package/skills/oris-flow/templates/debriefer.md +0 -19
- package/skills/oris-flow/templates/doctor.md +0 -22
- package/skills/oris-flow/templates/executor.md +0 -25
- package/skills/oris-flow/templates/orchestrator.md +0 -36
- package/skills/oris-flow/templates/verifier.md +0 -24
|
@@ -1,232 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,301 +0,0 @@
|
|
|
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
|
-
activeRoles,
|
|
20
|
-
assessPromptFiles,
|
|
21
|
-
improveMode,
|
|
22
|
-
normalizeModels,
|
|
23
|
-
validateLoopDocumentText,
|
|
24
|
-
} from "./oris-loop-document.mjs";
|
|
25
|
-
import { substitutePlaceholders } from "./oris-loop-templates.mjs";
|
|
26
|
-
import { ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
|
|
27
|
-
|
|
28
|
-
const ROLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
29
|
-
|
|
30
|
-
function fail(message) {
|
|
31
|
-
console.error(`[oris-skills] ERROR ${message}`);
|
|
32
|
-
process.exit(1);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function parseArgs(argv) {
|
|
36
|
-
const options = { repositoryRoot: "", loopSlug: "", agent: "codex", maxPasses: 0 };
|
|
37
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
38
|
-
const raw = argv[i];
|
|
39
|
-
const value = (flag) => {
|
|
40
|
-
if (raw.includes("=")) return raw.slice(raw.indexOf("=") + 1);
|
|
41
|
-
if (i + 1 >= argv.length) fail(`Missing value for ${flag}.`);
|
|
42
|
-
i += 1;
|
|
43
|
-
return argv[i];
|
|
44
|
-
};
|
|
45
|
-
if (raw === "--loop" || raw.startsWith("--loop=")) options.loopSlug = value("--loop");
|
|
46
|
-
else if (raw === "--agent" || raw.startsWith("--agent=")) options.agent = value("--agent");
|
|
47
|
-
else if (raw === "--max-passes" || raw.startsWith("--max-passes=")) options.maxPasses = Number.parseInt(value("--max-passes"), 10);
|
|
48
|
-
else if (raw === "--repository-root" || raw.startsWith("--repository-root=")) options.repositoryRoot = value("--repository-root");
|
|
49
|
-
else fail(`Unknown argument: ${raw}`);
|
|
50
|
-
}
|
|
51
|
-
options.agent = options.agent.toLowerCase();
|
|
52
|
-
if (!["codex", "claude"].includes(options.agent)) fail("--agent must be codex or claude.");
|
|
53
|
-
if (!options.loopSlug) fail("--loop <slug> is required.");
|
|
54
|
-
return options;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function getRoot(options) {
|
|
58
|
-
if (options.repositoryRoot) return path.resolve(options.repositoryRoot);
|
|
59
|
-
const result = childProcess.spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
60
|
-
cwd: process.cwd(),
|
|
61
|
-
shell: process.platform === "win32",
|
|
62
|
-
encoding: "utf8",
|
|
63
|
-
});
|
|
64
|
-
if (result.status !== 0 || !result.stdout.trim()) fail("Run inside a Git worktree or pass --repository-root.");
|
|
65
|
-
return path.resolve(result.stdout.trim());
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Roles flagged readonly must not get write access at the harness level. */
|
|
69
|
-
const READONLY_ROLES = new Set(["verifier", "doctor"]);
|
|
70
|
-
|
|
71
|
-
/** Build the CLI invocation for one role. Exported for tests. */
|
|
72
|
-
export function buildRoleInvocation(agent, { prompt, model, root, readonly = false }) {
|
|
73
|
-
if (agent === "claude") {
|
|
74
|
-
const args = ["-p", prompt, "--permission-mode", readonly ? "plan" : "acceptEdits"];
|
|
75
|
-
if (model && model !== "inherit") args.push("--model", model);
|
|
76
|
-
return { command: "claude", args, cwd: root };
|
|
77
|
-
}
|
|
78
|
-
const args = ["exec", "--sandbox", readonly ? "read-only" : "workspace-write", "--cd", root];
|
|
79
|
-
if (model && model !== "inherit") args.push("-m", model);
|
|
80
|
-
args.push(prompt);
|
|
81
|
-
return { command: "codex", args, cwd: root };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function runRole(agent, role, prompt, model, root, evidenceDir, pass) {
|
|
85
|
-
const invocation = buildRoleInvocation(agent, { prompt, model, root, readonly: READONLY_ROLES.has(role) });
|
|
86
|
-
process.stdout.write(` ${role} (${agent}${model && model !== "inherit" ? `, ${model}` : ""})... `);
|
|
87
|
-
const result = childProcess.spawnSync(invocation.command, invocation.args, {
|
|
88
|
-
cwd: invocation.cwd,
|
|
89
|
-
shell: process.platform === "win32",
|
|
90
|
-
encoding: "utf8",
|
|
91
|
-
timeout: ROLE_TIMEOUT_MS,
|
|
92
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
93
|
-
});
|
|
94
|
-
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
95
|
-
const evidencePath = path.join(evidenceDir, `pass-${pass}-${role}.log`);
|
|
96
|
-
fs.writeFileSync(evidencePath, output || "(no output)", "utf8");
|
|
97
|
-
if (result.error) {
|
|
98
|
-
console.log("FAILED");
|
|
99
|
-
fail(`${invocation.command} could not run (${result.error.message}). Is the ${agent} CLI installed and on PATH?`);
|
|
100
|
-
}
|
|
101
|
-
console.log(result.status === 0 ? "done" : `exit ${result.status}`);
|
|
102
|
-
return { status: result.status ?? 1, output, evidencePath };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Pull the last JSON object out of an agent's free-form output. */
|
|
106
|
-
export function extractJson(output) {
|
|
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
|
-
}
|
|
117
|
-
for (let start = text.lastIndexOf("{"); start !== -1; start = start === 0 ? -1 : text.lastIndexOf("{", start - 1)) {
|
|
118
|
-
for (let end = text.indexOf("}", start); end !== -1; end = text.indexOf("}", end + 1)) {
|
|
119
|
-
try {
|
|
120
|
-
return JSON.parse(text.slice(start, end + 1));
|
|
121
|
-
} catch {
|
|
122
|
-
// widen to the next closing brace
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
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
|
-
*/
|
|
135
|
-
export function doctorDecisionToState(decision) {
|
|
136
|
-
const value = String(decision ?? "").toLowerCase();
|
|
137
|
-
if (value === "complete") return "complete";
|
|
138
|
-
if (value === "advance") return "advance";
|
|
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
|
-
}
|
|
144
|
-
return "blocked";
|
|
145
|
-
}
|
|
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
|
-
|
|
158
|
-
function chatAction(root, args) {
|
|
159
|
-
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "oris-loop-chat.mjs");
|
|
160
|
-
const result = childProcess.spawnSync(process.execPath, [script, "--repository-root", root, ...args], {
|
|
161
|
-
cwd: root,
|
|
162
|
-
encoding: "utf8",
|
|
163
|
-
});
|
|
164
|
-
if (result.status !== 0) fail(`loop chat ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
165
|
-
return result.stdout;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function readContextSection(contextText, heading) {
|
|
169
|
-
const match = contextText.match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n## |$)`, "m"));
|
|
170
|
-
return match ? match[1].trim() : "";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function rolePrompt(promptsDir, name, values, extra) {
|
|
174
|
-
const text = fs.readFileSync(path.join(promptsDir, name), "utf8");
|
|
175
|
-
const filled = substitutePlaceholders(text, values);
|
|
176
|
-
return extra ? `${filled}\n\n${extra}` : filled;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function main() {
|
|
180
|
-
const options = parseArgs(process.argv.slice(2));
|
|
181
|
-
const root = getRoot(options);
|
|
182
|
-
const adapter = readAdapter(root);
|
|
183
|
-
const docs = resolveLoopDocumentPathsBySlug(root, options.loopSlug, adapter);
|
|
184
|
-
if (!fs.existsSync(docs.loopFull)) fail(`Loop not found: ${docs.loopRelative}`);
|
|
185
|
-
|
|
186
|
-
let loopDocument;
|
|
187
|
-
try {
|
|
188
|
-
loopDocument = validateLoopDocumentText(fs.readFileSync(docs.loopFull, "utf8"), docs.loopRelative);
|
|
189
|
-
} catch (error) {
|
|
190
|
-
fail(error.message);
|
|
191
|
-
}
|
|
192
|
-
const metadata = loopDocument.data;
|
|
193
|
-
const roles = activeRoles(metadata);
|
|
194
|
-
const prompts = assessPromptFiles(path.dirname(docs.loopFull), roles);
|
|
195
|
-
if (prompts.missing.length > 0) fail(`Loop prompt files are missing: ${prompts.missing.join(", ")}`);
|
|
196
|
-
const models = normalizeModels(metadata.models, adapter.models);
|
|
197
|
-
const mode = improveMode(metadata);
|
|
198
|
-
const maxPasses = options.maxPasses > 0 ? Math.min(options.maxPasses, metadata.limits.maxIterations) : metadata.limits.maxIterations;
|
|
199
|
-
const evidenceDir = path.join(root, ORIS_FLOW_RUNTIME, "evidence");
|
|
200
|
-
const receiptsDir = path.join(path.dirname(docs.loopFull), "receipts");
|
|
201
|
-
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
202
|
-
fs.mkdirSync(receiptsDir, { recursive: true });
|
|
203
|
-
|
|
204
|
-
console.log(`Oris loop headless run: ${metadata.name} (agent: ${options.agent}, roles: ${roles.join("+")}, improve: ${mode})`);
|
|
205
|
-
chatAction(root, ["--action", "start", "--loop", options.loopSlug, "--runner", "headless"]);
|
|
206
|
-
|
|
207
|
-
const maxNoProgress = Number(metadata.limits.maxNoProgress ?? 0);
|
|
208
|
-
let noProgressStreak = 0;
|
|
209
|
-
let state = "continue";
|
|
210
|
-
for (let pass = 1; pass <= maxPasses && (state === "continue" || state === "advance"); pass += 1) {
|
|
211
|
-
console.log(`Pass ${pass}/${maxPasses}:`);
|
|
212
|
-
const contextText = fs.readFileSync(docs.contextFull, "utf8");
|
|
213
|
-
const runtimeValues = {
|
|
214
|
-
iteration: pass,
|
|
215
|
-
phase: metadata.phases[0],
|
|
216
|
-
currentTarget: readContextSection(contextText, "Next action") || "(see context)",
|
|
217
|
-
};
|
|
218
|
-
const contextBlock = `CURRENT CONTEXT (${docs.contextRelative}):\n${contextText}`;
|
|
219
|
-
|
|
220
|
-
const executor = runRole(options.agent, "executor",
|
|
221
|
-
rolePrompt(prompts.promptsDir, "executor.md", runtimeValues, contextBlock),
|
|
222
|
-
models.executor, root, evidenceDir, pass);
|
|
223
|
-
const executorResult = extractJson(executor.output);
|
|
224
|
-
|
|
225
|
-
const verifier = runRole(options.agent, "verifier",
|
|
226
|
-
rolePrompt(prompts.promptsDir, "verifier.md", runtimeValues,
|
|
227
|
-
`EXECUTOR CLAIM:\n${JSON.stringify(executorResult ?? { raw: "see evidence" }, null, 2)}`),
|
|
228
|
-
models.verifier, root, evidenceDir, pass);
|
|
229
|
-
const verifierResult = extractJson(verifier.output);
|
|
230
|
-
|
|
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
|
-
}
|
|
248
|
-
|
|
249
|
-
if (roles.includes("debriefer") && mode === "auto" && fs.existsSync(path.join(prompts.promptsDir, "debriefer.md"))) {
|
|
250
|
-
runRole(options.agent, "debriefer",
|
|
251
|
-
rolePrompt(prompts.promptsDir, "debriefer.md", { ...runtimeValues, IMPROVE_MODE: mode },
|
|
252
|
-
`VERDICT:\n${JSON.stringify(doctorResult ?? verifierResult, null, 2)}\nPROMPTS DIR: ${docs.promptsRelative}`),
|
|
253
|
-
models.debriefer, root, evidenceDir, pass);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const progressed = verifierResult?.state === "pass"
|
|
257
|
-
|| (Array.isArray(executorResult?.changedFiles) && executorResult.changedFiles.length > 0);
|
|
258
|
-
noProgressStreak = progressed ? 0 : noProgressStreak + 1;
|
|
259
|
-
|
|
260
|
-
const receiptPath = path.join(receiptsDir, `pass-${String(pass).padStart(3, "0")}.md`);
|
|
261
|
-
fs.writeFileSync(receiptPath, `# Oris Loop Receipt
|
|
262
|
-
|
|
263
|
-
Loop: ${metadata.name}
|
|
264
|
-
Pass: ${pass}
|
|
265
|
-
Runner: headless (${options.agent})
|
|
266
|
-
Result: ${state}
|
|
267
|
-
|
|
268
|
-
Executor: ${executorResult?.summary ?? executorResult?.claim ?? "see evidence"}
|
|
269
|
-
Verifier: ${verifierResult?.state ?? "unknown"} — ${verifierResult?.summary ?? "see evidence"}
|
|
270
|
-
${doctor ? `Doctor: ${doctorResult?.decision ?? "unknown"} — ${doctorResult?.diagnosis ?? "see evidence"}\n` : ""}
|
|
271
|
-
Evidence:
|
|
272
|
-
- ${path.relative(root, executor.evidencePath).replace(/\\/g, "/")}
|
|
273
|
-
- ${path.relative(root, verifier.evidencePath).replace(/\\/g, "/")}
|
|
274
|
-
${doctor ? `- ${path.relative(root, doctor.evidencePath).replace(/\\/g, "/")}\n` : ""}`, "utf8");
|
|
275
|
-
|
|
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
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (state === "continue" || state === "advance") {
|
|
287
|
-
chatAction(root, ["--action", "set-state", "--state", "blocked"]);
|
|
288
|
-
console.log("Pass budget exhausted before completion — state set to blocked.");
|
|
289
|
-
process.exit(1);
|
|
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
|
-
}
|
|
295
|
-
console.log(`Headless run finished: ${state}`);
|
|
296
|
-
process.exit(state === "complete" ? 0 : 1);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
300
|
-
main();
|
|
301
|
-
}
|